feat: wireframe-to-code (#7334)

This commit is contained in:
David Luzar 2023-11-23 23:07:53 +01:00 committed by GitHub
parent d1e4421823
commit c7ee46e7f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2106 additions and 444 deletions

View File

@ -9,6 +9,7 @@ import {
} from "../components/icons"; } from "../components/icons";
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
@ -28,7 +29,7 @@ const alignActionsPredicate = (
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable aligning frames when implemented properly // TODO enable aligning frames when implemented properly
!selectedElements.some((el) => el.type === "frame") !selectedElements.some((el) => isFrameLikeElement(el))
); );
}; };

View File

@ -10,7 +10,7 @@ import { newElementWith } from "../element/mutateElement";
import { getElementsInGroup } from "../groups"; import { getElementsInGroup } from "../groups";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { fixBindingsAfterDeletion } from "../element/binding"; import { fixBindingsAfterDeletion } from "../element/binding";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { TrashIcon } from "../components/icons"; import { TrashIcon } from "../components/icons";
@ -20,7 +20,7 @@ const deleteSelectedElements = (
) => { ) => {
const framesToBeDeleted = new Set( const framesToBeDeleted = new Set(
getSelectedElements( getSelectedElements(
elements.filter((el) => el.type === "frame"), elements.filter((el) => isFrameLikeElement(el)),
appState, appState,
).map((el) => el.id), ).map((el) => el.id),
); );

View File

@ -5,6 +5,7 @@ import {
import { ToolButton } from "../components/ToolButton"; import { ToolButton } from "../components/ToolButton";
import { distributeElements, Distribution } from "../distribute"; import { distributeElements, Distribution } from "../distribute";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
@ -19,7 +20,7 @@ const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
return ( return (
selectedElements.length > 1 && selectedElements.length > 1 &&
// TODO enable distributing frames when implemented properly // TODO enable distributing frames when implemented properly
!selectedElements.some((el) => el.type === "frame") !selectedElements.some((el) => isFrameLikeElement(el))
); );
}; };

View File

@ -20,7 +20,7 @@ import {
bindTextToShapeAfterDuplication, bindTextToShapeAfterDuplication,
getBoundTextElement, getBoundTextElement,
} from "../element/textElement"; } from "../element/textElement";
import { isBoundToContainer, isFrameElement } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { normalizeElementOrder } from "../element/sortElements"; import { normalizeElementOrder } from "../element/sortElements";
import { DuplicateIcon } from "../components/icons"; import { DuplicateIcon } from "../components/icons";
import { import {
@ -140,11 +140,11 @@ const duplicateElements = (
} }
const boundTextElement = getBoundTextElement(element); const boundTextElement = getBoundTextElement(element);
const isElementAFrame = isFrameElement(element); const isElementAFrameLike = isFrameLikeElement(element);
if (idsOfElementsToDuplicate.get(element.id)) { if (idsOfElementsToDuplicate.get(element.id)) {
// if a group or a container/bound-text or frame, duplicate atomically // 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); const groupId = getSelectedGroupForElement(appState, element);
if (groupId) { if (groupId) {
// TODO: // TODO:
@ -154,7 +154,7 @@ const duplicateElements = (
sortedElements, sortedElements,
groupId, groupId,
).flatMap((element) => ).flatMap((element) =>
isFrameElement(element) isFrameLikeElement(element)
? [...getFrameChildren(elements, element.id), element] ? [...getFrameChildren(elements, element.id), element]
: [element], : [element],
); );
@ -180,7 +180,7 @@ const duplicateElements = (
); );
continue; continue;
} }
if (isElementAFrame) { if (isElementAFrameLike) {
const elementsInFrame = getFrameChildren(sortedElements, element.id); const elementsInFrame = getFrameChildren(sortedElements, element.id);
elementsWithClones.push( elementsWithClones.push(

View File

@ -1,4 +1,5 @@
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import { isFrameLikeElement } from "../element/typeChecks";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement } from "../element/types";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
@ -51,7 +52,7 @@ export const actionToggleElementLock = register({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: false, includeBoundTextElement: false,
}); });
if (selected.length === 1 && selected[0].type !== "frame") { if (selected.length === 1 && !isFrameLikeElement(selected[0])) {
return selected[0].locked return selected[0].locked
? "labels.elementLock.unlock" ? "labels.elementLock.unlock"
: "labels.elementLock.lock"; : "labels.elementLock.lock";

View File

@ -7,23 +7,27 @@ import { AppClassProperties, AppState } from "../types";
import { updateActiveTool } from "../utils"; import { updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { register } from "./register"; import { register } from "./register";
import { isFrameLikeElement } from "../element/typeChecks";
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => { const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
const selectedElements = app.scene.getSelectedElements(appState); 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({ export const actionSelectAllElementsInFrame = register({
name: "selectAllElementsInFrame", name: "selectAllElementsInFrame",
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState, _, app) => { 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( const elementsInFrame = getFrameChildren(
getNonDeletedElements(elements), getNonDeletedElements(elements),
selectedFrame.id, selectedElement.id,
).filter((element) => !(element.type === "text" && element.containerId)); ).filter((element) => !(element.type === "text" && element.containerId));
return { return {
@ -54,15 +58,20 @@ export const actionRemoveAllElementsFromFrame = register({
name: "removeAllElementsFromFrame", name: "removeAllElementsFromFrame",
trackEvent: { category: "history" }, trackEvent: { category: "history" },
perform: (elements, appState, _, app) => { 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 { return {
elements: removeAllElementsFromFrame(elements, selectedFrame, appState), elements: removeAllElementsFromFrame(
elements,
selectedElement,
appState,
),
appState: { appState: {
...appState, ...appState,
selectedElementIds: { selectedElementIds: {
[selectedFrame.id]: true, [selectedElement.id]: true,
}, },
}, },
commitToHistory: true, commitToHistory: true,

View File

@ -22,8 +22,8 @@ import { AppClassProperties, AppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer } from "../element/typeChecks";
import { import {
getElementsInResizingFrame, getElementsInResizingFrame,
getFrameElements, getFrameLikeElements,
groupByFrames, groupByFrameLikes,
removeElementsFromFrame, removeElementsFromFrame,
replaceAllElementsInFrame, replaceAllElementsInFrame,
} from "../frame"; } from "../frame";
@ -102,7 +102,7 @@ export const actionGroup = register({
// when it happens, we want to remove elements that are in the frame // 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) // and are going to be grouped from the frame (mouthful, I know)
if (groupingElementsFromDifferentFrames) { if (groupingElementsFromDifferentFrames) {
const frameElementsMap = groupByFrames(selectedElements); const frameElementsMap = groupByFrameLikes(selectedElements);
frameElementsMap.forEach((elementsInFrame, frameId) => { frameElementsMap.forEach((elementsInFrame, frameId) => {
nextElements = removeElementsFromFrame( nextElements = removeElementsFromFrame(
@ -219,7 +219,7 @@ export const actionUngroup = register({
.map((element) => element.frameId!), .map((element) => element.frameId!),
); );
const targetFrames = getFrameElements(elements).filter((frame) => const targetFrames = getFrameLikeElements(elements).filter((frame) =>
selectedElementFrameIds.has(frame.id), selectedElementFrameIds.has(frame.id),
); );

View File

@ -20,7 +20,7 @@ import {
hasBoundTextElement, hasBoundTextElement,
canApplyRoundnessTypeToElement, canApplyRoundnessTypeToElement,
getDefaultRoundnessTypeForElement, getDefaultRoundnessTypeForElement,
isFrameElement, isFrameLikeElement,
isArrowElement, isArrowElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
@ -138,7 +138,7 @@ export const actionPasteStyles = register({
}); });
} }
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
newElement = newElementWith(newElement, { newElement = newElementWith(newElement, {
roundness: null, roundness: null,
backgroundColor: "transparent", backgroundColor: "transparent",

View File

@ -9,7 +9,10 @@ import {
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
MIME_TYPES, MIME_TYPES,
} from "./constants"; } from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import {
isFrameLikeElement,
isInitializedImageElement,
} from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
@ -124,7 +127,7 @@ export const serializeAsClipboardJSON = ({
files: BinaryFiles | null; files: BinaryFiles | null;
}) => { }) => {
const framesToCopy = new Set( const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"), elements.filter((element) => isFrameLikeElement(element)),
); );
let foundFile = false; let foundFile = false;

View File

@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import { ExcalidrawElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawElementType } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
import { useDevice } from "../components/App"; import { useDevice } from "../components/App";
import { import {
@ -36,6 +36,8 @@ import {
frameToolIcon, frameToolIcon,
mermaidLogoIcon, mermaidLogoIcon,
laserPointerToolIcon, laserPointerToolIcon,
OpenAIIcon,
MagicIcon,
} from "./icons"; } from "./icons";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -79,7 +81,8 @@ export const SelectedShapeActions = ({
const showLinkIcon = const showLinkIcon =
targetElements.length === 1 || isSingleElementBoundContainer; 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) { for (const element of targetElements) {
if (element.type !== commonSelectedType) { if (element.type !== commonSelectedType) {
@ -94,7 +97,8 @@ export const SelectedShapeActions = ({
{((hasStrokeColor(appState.activeTool.type) && {((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" && appState.activeTool.type !== "image" &&
commonSelectedType !== "image" && commonSelectedType !== "image" &&
commonSelectedType !== "frame") || commonSelectedType !== "frame" &&
commonSelectedType !== "magicframe") ||
targetElements.some((element) => hasStrokeColor(element.type))) && targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")} renderAction("changeStrokeColor")}
</div> </div>
@ -331,6 +335,9 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.laser")} {t("toolBar.laser")}
</DropdownMenu.Item> </DropdownMenu.Item>
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
Generate
</div>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => app.setOpenDialog("mermaid")} onSelect={() => app.setOpenDialog("mermaid")}
icon={mermaidLogoIcon} icon={mermaidLogoIcon}
@ -338,6 +345,25 @@ export const ShapesSwitcher = ({
> >
{t("toolBar.mermaidToExcalidraw")} {t("toolBar.mermaidToExcalidraw")}
</DropdownMenu.Item> </DropdownMenu.Item>
{app.props.aiEnabled !== false && (
<>
<DropdownMenu.Item
onSelect={() => app.onMagicButtonSelect()}
icon={MagicIcon}
data-testid="toolbar-magicframe"
>
{t("toolBar.magicframe")}
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => app.setOpenDialog("magicSettings")}
icon={OpenAIIcon}
data-testid="toolbar-magicSettings"
>
{t("toolBar.magicSettings")}
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</> </>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,12 @@
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { ActionManager } from "../actions/manager"; 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 { showSelectedShapeActions } from "../element";
import { NonDeletedExcalidrawElement } from "../element/types"; import { NonDeletedExcalidrawElement } from "../element/types";
import { Language, t } from "../i18n"; import { Language, t } from "../i18n";
@ -56,6 +61,7 @@ import { mutateElement } from "../element/mutateElement";
import { ShapeCache } from "../scene/ShapeCache"; import { ShapeCache } from "../scene/ShapeCache";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { LaserPointerButton } from "./LaserTool/LaserPointerButton"; import { LaserPointerButton } from "./LaserTool/LaserPointerButton";
import { MagicSettings } from "./MagicSettings";
interface LayerUIProps { interface LayerUIProps {
actionManager: ActionManager; actionManager: ActionManager;
@ -77,6 +83,10 @@ interface LayerUIProps {
children?: React.ReactNode; children?: React.ReactNode;
app: AppClassProperties; app: AppClassProperties;
isCollaborating: boolean; isCollaborating: boolean;
openAIKey: string | null;
isOpenAIKeyPersisted: boolean;
onOpenAIAPIKeyChange: (apiKey: string, shouldPersist: boolean) => void;
onMagicSettingsConfirm: (apiKey: string, shouldPersist: boolean) => void;
} }
const DefaultMainMenu: React.FC<{ const DefaultMainMenu: React.FC<{
@ -133,6 +143,10 @@ const LayerUI = ({
children, children,
app, app,
isCollaborating, isCollaborating,
openAIKey,
isOpenAIKeyPersisted,
onOpenAIAPIKeyChange,
onMagicSettingsConfirm,
}: LayerUIProps) => { }: LayerUIProps) => {
const device = useDevice(); const device = useDevice();
const tunnels = useInitializeTunnels(); const tunnels = useInitializeTunnels();
@ -295,9 +309,11 @@ const LayerUI = ({
> >
<LaserPointerButton <LaserPointerButton
title={t("toolBar.laser")} title={t("toolBar.laser")}
checked={appState.activeTool.type === "laser"} checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() => onChange={() =>
app.setActiveTool({ type: "laser" }) app.setActiveTool({ type: TOOL_TYPE.laser })
} }
isMobile isMobile
/> />
@ -439,6 +455,20 @@ const LayerUI = ({
}} }}
/> />
)} )}
{appState.openDialog === "magicSettings" && (
<MagicSettings
openAIKey={openAIKey}
isPersisted={isOpenAIKeyPersisted}
onChange={onOpenAIAPIKeyChange}
onConfirm={(apiKey, shouldPersist) => {
setAppState({ openDialog: null });
onMagicSettingsConfirm(apiKey, shouldPersist);
}}
onClose={() => {
setAppState({ openDialog: null });
}}
/>
)}
<ActiveConfirmDialog /> <ActiveConfirmDialog />
<tunnels.OverwriteConfirmDialogTunnel.Out /> <tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()} {renderImageExportDialog()}

View 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>
);
};

View File

@ -0,0 +1,9 @@
.excalidraw {
.MagicSettings-confirm {
padding: 0.5rem 1rem;
}
.MagicSettings__confirm {
margin-top: 2rem;
}
}

View File

@ -0,0 +1,145 @@
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";
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>
);
};
export const MagicSettings = (props: {
openAIKey: string | null;
isPersisted: boolean;
onChange: (key: string, shouldPersist: boolean) => void;
onConfirm: (key: string, shouldPersist: boolean) => void;
onClose: () => void;
}) => {
const { theme } = useUIAppState();
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || "");
const [shouldPersist, setShouldPersist] = useState<boolean>(
props.isPersisted,
);
const onConfirm = () => {
props.onConfirm(keyInputValue.trim(), shouldPersist);
};
return (
<Dialog
onCloseRequest={() => {
props.onClose();
props.onConfirm(keyInputValue.trim(), shouldPersist);
}}
title={
<div style={{ display: "flex" }}>
Diagram to Code (AI){" "}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.1rem 0.5rem",
marginLeft: "1rem",
fontSize: 14,
borderRadius: "12px",
background: theme === "light" ? "#FFCCCC" : "#703333",
}}
>
Experimental
</div>
</div>
}
className="MagicSettings"
autofocus={false}
>
<p
style={{
display: "inline-flex",
alignItems: "center",
marginBottom: 0,
}}
>
For the diagram-to-code feature we use{" "}
<InlineButton icon={OpenAIIcon} />
OpenAI.
</p>
<p>
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>
.
</p>
<p>
Your OpenAI key does not leave the browser, and you can also set your
own limit in your OpenAI account dashboard if needed.
</p>
<TextField
isPassword
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()}
/>
<p>
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>
<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} />{" "}
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>
<FilledButton
className="MagicSettings__confirm"
size="large"
label="Confirm"
onClick={onConfirm}
/>
</Dialog>
);
};

View File

@ -94,7 +94,7 @@ export const PasteChartDialog = ({
const handleChartClick = (chartType: ChartType, elements: ChartElements) => { const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
onInsertElements(elements); onInsertElements(elements);
trackEvent("magic", "chart", chartType); trackEvent("paste", "chart", chartType);
setAppState({ setAppState({
currentChartType: chartType, currentChartType: chartType,
pasteDialog: { pasteDialog: {

View File

@ -8,6 +8,7 @@ import Trans from "./Trans";
import { LibraryItems, LibraryItem, UIAppState } from "../types"; import { LibraryItems, LibraryItem, UIAppState } from "../types";
import { exportToCanvas, exportToSvg } from "../packages/utils"; import { exportToCanvas, exportToSvg } from "../packages/utils";
import { import {
EDITOR_LS_KEYS,
EXPORT_DATA_TYPES, EXPORT_DATA_TYPES,
EXPORT_SOURCE, EXPORT_SOURCE,
MIME_TYPES, MIME_TYPES,
@ -19,6 +20,7 @@ import { chunk } from "../utils";
import DialogActionButton from "./DialogActionButton"; import DialogActionButton from "./DialogActionButton";
import { CloseIcon } from "./icons"; import { CloseIcon } from "./icons";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
import { EditorLocalStorage } from "../data/EditorLocalStorage";
import "./PublishLibrary.scss"; import "./PublishLibrary.scss";
@ -31,34 +33,6 @@ interface PublishLibraryDataParams {
website: string; 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 generatePreviewImage = async (libraryItems: LibraryItems) => {
const MAX_ITEMS_PER_ROW = 6; const MAX_ITEMS_PER_ROW = 6;
const BOX_SIZE = 128; const BOX_SIZE = 128;
@ -255,7 +229,9 @@ const PublishLibrary = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
const data = importPublishLibDataFromStorage(); const data = EditorLocalStorage.get<PublishLibraryDataParams>(
EDITOR_LS_KEYS.PUBLISH_LIBRARY,
);
if (data) { if (data) {
setLibraryData(data); setLibraryData(data);
} }
@ -328,7 +304,7 @@ const PublishLibrary = ({
if (response.ok) { if (response.ok) {
return response.json().then(({ url }) => { return response.json().then(({ url }) => {
// flush data from local storage // flush data from local storage
localStorage.removeItem(LOCAL_STORAGE_KEY_PUBLISH_LIBRARY); EditorLocalStorage.delete(EDITOR_LS_KEYS.PUBLISH_LIBRARY);
onSuccess({ onSuccess({
url, url,
authorName: libraryData.authorName, authorName: libraryData.authorName,
@ -384,7 +360,7 @@ const PublishLibrary = ({
const onDialogClose = useCallback(() => { const onDialogClose = useCallback(() => {
updateItemsInStorage(clonedLibItems); updateItemsInStorage(clonedLibItems);
savePublishLibDataToStorage(libraryData); EditorLocalStorage.set(EDITOR_LS_KEYS.PUBLISH_LIBRARY, libraryData);
onClose(); onClose();
}, [clonedLibItems, onClose, updateItemsInStorage, libraryData]); }, [clonedLibItems, onClose, updateItemsInStorage, libraryData]);

View File

@ -4,12 +4,15 @@ import {
useImperativeHandle, useImperativeHandle,
KeyboardEvent, KeyboardEvent,
useLayoutEffect, useLayoutEffect,
useState,
} from "react"; } from "react";
import clsx from "clsx"; import clsx from "clsx";
import "./TextField.scss"; import "./TextField.scss";
import { Button } from "./Button";
import { eyeIcon, eyeClosedIcon } from "./icons";
export type TextFieldProps = { type TextFieldProps = {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@ -22,6 +25,7 @@ export type TextFieldProps = {
label?: string; label?: string;
placeholder?: string; placeholder?: string;
isPassword?: boolean;
}; };
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>( export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
@ -35,6 +39,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
readonly, readonly,
selectOnRender, selectOnRender,
onKeyDown, onKeyDown,
isPassword = false,
}, },
ref, ref,
) => { ) => {
@ -48,6 +53,8 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
} }
}, [selectOnRender]); }, [selectOnRender]);
const [isVisible, setIsVisible] = useState<boolean>(true);
return ( return (
<div <div
className={clsx("ExcTextField", { className={clsx("ExcTextField", {
@ -64,14 +71,22 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
})} })}
> >
<input <input
type={isPassword && isVisible ? "password" : undefined}
readOnly={readonly} readOnly={readonly}
type="text"
value={value} value={value}
placeholder={placeholder} placeholder={placeholder}
ref={innerRef} ref={innerRef}
onChange={(event) => onChange?.(event.target.value)} onChange={(event) => onChange?.(event.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{isPassword && (
<Button
onSelect={() => setIsVisible(!isVisible)}
style={{ border: 0 }}
>
{isVisible ? eyeIcon : eyeClosedIcon}
</Button>
)}
</div> </div>
</div> </div>
); );

View File

@ -175,7 +175,8 @@
} }
} }
.ToolIcon__LaserPointer .ToolIcon__icon { .ToolIcon__LaserPointer .ToolIcon__icon,
.ToolIcon__MagicButton .ToolIcon__icon {
width: var(--default-button-size); width: var(--default-button-size);
height: var(--default-button-size); height: var(--default-button-size);
} }

View File

@ -189,8 +189,6 @@ const getRelevantAppStateProps = (
suggestedBindings: appState.suggestedBindings, suggestedBindings: appState.suggestedBindings,
isRotating: appState.isRotating, isRotating: appState.isRotating,
elementsToHighlight: appState.elementsToHighlight, elementsToHighlight: appState.elementsToHighlight,
openSidebar: appState.openSidebar,
showHyperlinkPopup: appState.showHyperlinkPopup,
collaborators: appState.collaborators, // Necessary for collab. sessions collaborators: appState.collaborators, // Necessary for collab. sessions
activeEmbeddable: appState.activeEmbeddable, activeEmbeddable: appState.activeEmbeddable,
snapLines: appState.snapLines, snapLines: appState.snapLines,

View File

@ -1688,3 +1688,57 @@ export const laserPointerToolIcon = createIcon(
20, 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,
);

View File

@ -80,6 +80,7 @@ export enum EVENT {
EXCALIDRAW_LINK = "excalidraw-link", EXCALIDRAW_LINK = "excalidraw-link",
MENU_ITEM_SELECT = "menu.itemSelect", MENU_ITEM_SELECT = "menu.itemSelect",
MESSAGE = "message", MESSAGE = "message",
FULLSCREENCHANGE = "fullscreenchange",
} }
export const YOUTUBE_STATES = { export const YOUTUBE_STATES = {
@ -344,4 +345,33 @@ export const DEFAULT_SIDEBAR = {
defaultTab: LIBRARY_SIDEBAR_TAB, defaultTab: LIBRARY_SIDEBAR_TAB,
} as const; } 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;

View File

@ -5,9 +5,11 @@
--zIndex-canvas: 1; --zIndex-canvas: 1;
--zIndex-interactiveCanvas: 2; --zIndex-interactiveCanvas: 2;
--zIndex-wysiwyg: 3; --zIndex-wysiwyg: 3;
--zIndex-canvasButtons: 3;
--zIndex-layerUI: 4; --zIndex-layerUI: 4;
--zIndex-eyeDropperBackdrop: 5; --zIndex-eyeDropperBackdrop: 5;
--zIndex-eyeDropperPreview: 6; --zIndex-eyeDropperPreview: 6;
--zIndex-hyperlinkContainer: 7;
--zIndex-modal: 1000; --zIndex-modal: 1000;
--zIndex-popup: 1001; --zIndex-popup: 1001;

View 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}`);
}
};
}

300
src/data/ai/types.ts Normal file
View 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;
}
}

View File

@ -3,10 +3,11 @@ import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
import { getNonDeletedElements, isFrameElement } from "../element"; import { getNonDeletedElements } from "../element";
import { isFrameLikeElement } from "../element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { t } from "../i18n"; import { t } from "../i18n";
@ -38,7 +39,7 @@ export const prepareElementsForExport = (
exportSelectionOnly && exportSelectionOnly &&
isSomeElementSelected(elements, { selectedElementIds }); isSomeElementSelected(elements, { selectedElementIds });
let exportingFrame: ExcalidrawFrameElement | null = null; let exportingFrame: ExcalidrawFrameLikeElement | null = null;
let exportedElements = isExportingSelection let exportedElements = isExportingSelection
? getSelectedElements( ? getSelectedElements(
elements, elements,
@ -50,7 +51,10 @@ export const prepareElementsForExport = (
: elements; : elements;
if (isExportingSelection) { if (isExportingSelection) {
if (exportedElements.length === 1 && isFrameElement(exportedElements[0])) { if (
exportedElements.length === 1 &&
isFrameLikeElement(exportedElements[0])
) {
exportingFrame = exportedElements[0]; exportingFrame = exportedElements[0];
exportedElements = elementsOverlappingBBox({ exportedElements = elementsOverlappingBBox({
elements, elements,
@ -93,7 +97,7 @@ export const exportCanvas = async (
viewBackgroundColor: string; viewBackgroundColor: string;
name: string; name: string;
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
exportingFrame: ExcalidrawFrameElement | null; exportingFrame: ExcalidrawFrameLikeElement | null;
}, },
) => { ) => {
if (elements.length === 0) { if (elements.length === 0) {

104
src/data/magic.ts Normal file
View 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;
}

View File

@ -1,5 +1,6 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FontFamilyValues, FontFamilyValues,
@ -68,6 +69,7 @@ export const AllowedExcalidrawActiveTools: Record<
embeddable: true, embeddable: true,
hand: true, hand: true,
laser: false, laser: false,
magicframe: false,
}; };
export type RestoredDataState = { export type RestoredDataState = {
@ -111,7 +113,7 @@ const restoreElementWithProperties = <
// @ts-ignore TS complains here but type checks the call sites fine. // @ts-ignore TS complains here but type checks the call sites fine.
keyof K keyof K
> & > &
Partial<Pick<ExcalidrawElement, "type" | "x" | "y">>, Partial<Pick<ExcalidrawElement, "type" | "x" | "y" | "customData">>,
): T => { ): T => {
const base: Pick<T, keyof ExcalidrawElement> & { const base: Pick<T, keyof ExcalidrawElement> & {
[PRECEDING_ELEMENT_KEY]?: string; [PRECEDING_ELEMENT_KEY]?: string;
@ -159,8 +161,9 @@ const restoreElementWithProperties = <
locked: element.locked ?? false, locked: element.locked ?? false,
}; };
if ("customData" in element) { if ("customData" in element || "customData" in extra) {
base.customData = element.customData; base.customData =
"customData" in extra ? extra.customData : element.customData;
} }
if (PRECEDING_ELEMENT_KEY in element) { if (PRECEDING_ELEMENT_KEY in element) {
@ -273,7 +276,7 @@ const restoreElement = (
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
type: type:
(element.type as ExcalidrawElement["type"] | "draw") === "draw" (element.type as ExcalidrawElementType | "draw") === "draw"
? "line" ? "line"
: element.type, : element.type,
startBinding: repairBinding(element.startBinding), startBinding: repairBinding(element.startBinding),
@ -289,15 +292,15 @@ const restoreElement = (
// generic elements // generic elements
case "ellipse": case "ellipse":
return restoreElementWithProperties(element, {});
case "rectangle": case "rectangle":
return restoreElementWithProperties(element, {});
case "diamond": case "diamond":
case "iframe":
return restoreElementWithProperties(element, {}); return restoreElementWithProperties(element, {});
case "embeddable": case "embeddable":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
validated: null, validated: null,
}); });
case "magicframe":
case "frame": case "frame":
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
name: element.name ?? null, name: element.name ?? null,

View File

@ -15,6 +15,7 @@ import {
ElementConstructorOpts, ElementConstructorOpts,
newFrameElement, newFrameElement,
newImageElement, newImageElement,
newMagicFrameElement,
newTextElement, newTextElement,
} from "../element/newElement"; } from "../element/newElement";
import { import {
@ -26,12 +27,13 @@ import {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEmbeddableElement,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawIframeLikeElement,
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawMagicFrameElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FileId, FileId,
@ -61,7 +63,12 @@ export type ValidLinearElement = {
| { | {
type: Exclude< type: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
id?: ExcalidrawGenericElement["id"]; id?: ExcalidrawGenericElement["id"];
} }
@ -69,7 +76,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"]; id: ExcalidrawGenericElement["id"];
type?: Exclude< type?: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
} }
) )
@ -93,7 +105,12 @@ export type ValidLinearElement = {
| { | {
type: Exclude< type: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
id?: ExcalidrawGenericElement["id"]; id?: ExcalidrawGenericElement["id"];
} }
@ -101,7 +118,12 @@ export type ValidLinearElement = {
id: ExcalidrawGenericElement["id"]; id: ExcalidrawGenericElement["id"];
type?: Exclude< type?: Exclude<
ExcalidrawBindableElement["type"], ExcalidrawBindableElement["type"],
"image" | "text" | "frame" | "embeddable" | "image"
| "text"
| "frame"
| "magicframe"
| "embeddable"
| "iframe"
>; >;
} }
) )
@ -137,7 +159,7 @@ export type ValidContainer =
export type ExcalidrawElementSkeleton = export type ExcalidrawElementSkeleton =
| Extract< | Extract<
Exclude<ExcalidrawElement, ExcalidrawSelectionElement>, Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
ExcalidrawEmbeddableElement | ExcalidrawFreeDrawElement ExcalidrawIframeLikeElement | ExcalidrawFreeDrawElement
> >
| ({ | ({
type: Extract<ExcalidrawLinearElement["type"], "line">; type: Extract<ExcalidrawLinearElement["type"], "line">;
@ -163,7 +185,12 @@ export type ExcalidrawElementSkeleton =
type: "frame"; type: "frame";
children: readonly ExcalidrawElement["id"][]; children: readonly ExcalidrawElement["id"][];
name?: string; name?: string;
} & Partial<ExcalidrawFrameElement>); } & Partial<ExcalidrawFrameElement>)
| ({
type: "magicframe";
children: readonly ExcalidrawElement["id"][];
name?: string;
} & Partial<ExcalidrawMagicFrameElement>);
const DEFAULT_LINEAR_ELEMENT_PROPS = { const DEFAULT_LINEAR_ELEMENT_PROPS = {
width: 100, width: 100,
@ -547,7 +574,16 @@ export const convertToExcalidrawElements = (
}); });
break; break;
} }
case "magicframe": {
excalidrawElement = newMagicFrameElement({
x: 0,
y: 0,
...element,
});
break;
}
case "freedraw": case "freedraw":
case "iframe":
case "embeddable": { case "embeddable": {
excalidrawElement = element; excalidrawElement = element;
break; break;
@ -656,7 +692,7 @@ export const convertToExcalidrawElements = (
// need to calculate coordinates and dimensions of frame which is possibe after all // need to calculate coordinates and dimensions of frame which is possibe after all
// frame children are processed. // frame children are processed.
for (const [id, element] of elementsWithIds) { for (const [id, element] of elementsWithIds) {
if (element.type !== "frame") { if (element.type !== "frame" && element.type !== "magicframe") {
continue; continue;
} }
const frame = elementStore.getElement(id); const frame = elementStore.getElement(id);

View 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;
}
}

View 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>
);
};

View File

@ -6,7 +6,7 @@
justify-content: space-between; justify-content: space-between;
position: absolute; position: absolute;
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%); box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
z-index: 100; z-index: var(--zIndex-hyperlinkContainer);
background: var(--island-bg-color); background: var(--island-bg-color);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
box-sizing: border-box; box-sizing: border-box;

View File

@ -121,7 +121,7 @@ export const Hyperlink = ({
setToast({ message: embedLink.warning, closable: true }); setToast({ message: embedLink.warning, closable: true });
} }
const ar = embedLink const ar = embedLink
? embedLink.aspectRatio.w / embedLink.aspectRatio.h ? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
: 1; : 1;
const hasLinkChanged = const hasLinkChanged =
embeddableLinkCache.get(element.id) !== element.link; embeddableLinkCache.get(element.id) !== element.link;
@ -210,6 +210,7 @@ export const Hyperlink = ({
}; };
const { x, y } = getCoordsForPopover(element, appState); const { x, y } = getCoordsForPopover(element, appState);
if ( if (
appState.contextMenu ||
appState.draggingElement || appState.draggingElement ||
appState.resizingElement || appState.resizingElement ||
appState.isRotating || appState.isRotating ||

View File

@ -18,7 +18,6 @@ import {
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawElement, ExcalidrawElement,
ExcalidrawRectangleElement, ExcalidrawRectangleElement,
ExcalidrawEmbeddableElement,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawEllipseElement, ExcalidrawEllipseElement,
@ -27,7 +26,8 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
ExcalidrawIframeLikeElement,
} from "./types"; } from "./types";
import { import {
@ -41,7 +41,8 @@ import { Drawable } from "roughjs/bin/core";
import { AppState } from "../types"; import { AppState } from "../types";
import { import {
hasBoundTextElement, hasBoundTextElement,
isEmbeddableElement, isFrameLikeElement,
isIframeLikeElement,
isImageElement, isImageElement,
} from "./typeChecks"; } from "./typeChecks";
import { isTextElement } from "."; import { isTextElement } from ".";
@ -64,7 +65,7 @@ const isElementDraggableFromInside = (
const isDraggableFromInside = const isDraggableFromInside =
!isTransparent(element.backgroundColor) || !isTransparent(element.backgroundColor) ||
hasBoundTextElement(element) || hasBoundTextElement(element) ||
isEmbeddableElement(element); isIframeLikeElement(element);
if (element.type === "line") { if (element.type === "line") {
return isDraggableFromInside && isPathALoop(element.points); return isDraggableFromInside && isPathALoop(element.points);
} }
@ -186,7 +187,7 @@ export const isPointHittingElementBoundingBox = (
// by its frame, whether it has been selected or not // by its frame, whether it has been selected or not
// this logic here is not ideal // this logic here is not ideal
// TODO: refactor it later... // TODO: refactor it later...
if (element.type === "frame") { if (isFrameLikeElement(element)) {
return hitTestPointAgainstElement({ return hitTestPointAgainstElement({
element, element,
point: [x, y], point: [x, y],
@ -255,6 +256,7 @@ type HitTestArgs = {
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
switch (args.element.type) { switch (args.element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "image": case "image":
case "text": case "text":
@ -282,7 +284,8 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
"This should not happen, we need to investigate why it does.", "This should not happen, we need to investigate why it does.",
); );
return false; return false;
case "frame": { case "frame":
case "magicframe": {
// check distance to frame element first // check distance to frame element first
if ( if (
args.check( args.check(
@ -314,8 +317,10 @@ export const distanceToBindableElement = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
return distanceToRectangle(element, point); return distanceToRectangle(element, point);
case "diamond": case "diamond":
return distanceToDiamond(element, point); return distanceToDiamond(element, point);
@ -346,8 +351,8 @@ const distanceToRectangle = (
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
point: Point, point: Point,
): number => { ): number => {
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
@ -662,8 +667,10 @@ export const determineFocusDistance = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
ret = c / (hwidth * (nabs + q * mabs)); ret = c / (hwidth * (nabs + q * mabs));
break; break;
case "diamond": case "diamond":
@ -700,8 +707,10 @@ export const determineFocusPoint = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
point = findFocusPointForRectangulars(element, focus, adjecentPointRel); point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
break; break;
case "ellipse": case "ellipse":
@ -752,8 +761,10 @@ const getSortedElementLineIntersections = (
case "image": case "image":
case "text": case "text":
case "diamond": case "diamond":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
const corners = getCorners(element); const corners = getCorners(element);
intersections = corners intersections = corners
.flatMap((point, i) => { .flatMap((point, i) => {
@ -788,8 +799,8 @@ const getCorners = (
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
scale: number = 1, scale: number = 1,
): GA.Point[] => { ): GA.Point[] => {
const hx = (scale * element.width) / 2; const hx = (scale * element.width) / 2;
@ -798,8 +809,10 @@ const getCorners = (
case "rectangle": case "rectangle":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
return [ return [
GA.point(hx, hy), GA.point(hx, hy),
GA.point(hx, -hy), GA.point(hx, -hy),
@ -948,8 +961,8 @@ export const findFocusPointForRectangulars = (
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawDiamondElement | ExcalidrawDiamondElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawEmbeddableElement | ExcalidrawIframeLikeElement
| ExcalidrawFrameElement, | ExcalidrawFrameLikeElement,
// Between -1 and 1 for how far away should the focus point be relative // Between -1 and 1 for how far away should the focus point be relative
// to the size of the element. Sign determines orientation. // to the size of the element. Sign determines orientation.
relativeDistance: number, relativeDistance: number,

View File

@ -11,7 +11,7 @@ import Scene from "../scene/Scene";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement, isFrameLikeElement,
} from "./typeChecks"; } from "./typeChecks";
export const dragSelectedElements = ( export const dragSelectedElements = (
@ -33,7 +33,7 @@ export const dragSelectedElements = (
selectedElements, selectedElements,
); );
const frames = selectedElements const frames = selectedElements
.filter((e) => isFrameElement(e)) .filter((e) => isFrameLikeElement(e))
.map((f) => f.id); .map((f) => f.id);
if (frames.length > 0) { if (frames.length > 0) {

View File

@ -6,25 +6,19 @@ import { getFontString, updateActiveTool } from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { getContainerElement, wrapText } from "./textElement"; import { getContainerElement, wrapText } from "./textElement";
import { isEmbeddableElement } from "./typeChecks"; import {
isFrameLikeElement,
isIframeElement,
isIframeLikeElement,
} from "./typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawEmbeddableElement, ExcalidrawIframeLikeElement,
IframeData,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Theme,
} from "./types"; } from "./types";
type EmbeddedLink = const embeddedLinkCache = new Map<string, IframeData>();
| ({
aspectRatio: { w: number; h: number };
warning?: string;
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
))
| null;
const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE = 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]*$/;
@ -67,11 +61,13 @@ const ALLOWED_DOMAINS = new Set([
"dddice.com", "dddice.com",
]); ]);
const createSrcDoc = (body: string) => { export const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`; 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) { if (!link) {
return null; return null;
} }
@ -104,8 +100,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
break; break;
} }
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 }; aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
const vimeoLink = link.match(RE_VIMEO); const vimeoLink = link.match(RE_VIMEO);
@ -119,8 +119,12 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
aspectRatio = { w: 560, h: 315 }; aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link //warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache //same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type, warning }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type, warning };
} }
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
@ -130,27 +134,35 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
link, link,
)}`; )}`;
aspectRatio = { w: 550, h: 550 }; aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
const valLink = link.match(RE_VALTOWN); const valLink = link.match(RE_VALTOWN);
if (valLink) { if (valLink) {
link = link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed"); valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
intrinsicSize: aspectRatio,
type,
});
return { link, intrinsicSize: aspectRatio, type };
} }
if (RE_TWITTER.test(link)) { if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink; let ret: IframeData;
// assume embed code // assume embed code
if (/<blockquote/.test(link)) { if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link); const srcDoc = createSrcDoc(link);
ret = { ret = {
type: "document", type: "document",
srcdoc: () => srcDoc, srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 }, intrinsicSize: { w: 480, h: 480 },
}; };
// assume regular tweet url // assume regular tweet url
} else { } else {
@ -160,7 +172,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
createSrcDoc( 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>`, `<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 }, intrinsicSize: { w: 480, h: 480 },
}; };
} }
embeddedLinkCache.set(originalLink, ret); embeddedLinkCache.set(originalLink, ret);
@ -168,14 +180,14 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
} }
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink; let ret: IframeData;
// assume embed code // assume embed code
if (/<script>/.test(link)) { if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link); const srcDoc = createSrcDoc(link);
ret = { ret = {
type: "document", type: "document",
srcdoc: () => srcDoc, srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 }, intrinsicSize: { w: 550, h: 720 },
}; };
// assume regular url // assume regular url
} else { } else {
@ -190,26 +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; } .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style> </style>
`), `),
aspectRatio: { w: 550, h: 720 }, intrinsicSize: { w: 550, h: 720 },
}; };
} }
embeddedLinkCache.set(link, ret); embeddedLinkCache.set(link, ret);
return ret; return ret;
} }
embeddedLinkCache.set(link, { link, aspectRatio, type }); embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type });
return { link, aspectRatio, type }; return { link, intrinsicSize: aspectRatio, type };
}; };
export const isEmbeddableOrLabel = ( export const isIframeLikeOrItsLabel = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
): Boolean => { ): Boolean => {
if (isEmbeddableElement(element)) { if (isIframeLikeElement(element)) {
return true; return true;
} }
if (element.type === "text") { if (element.type === "text") {
const container = getContainerElement(element); const container = getContainerElement(element);
if (container && isEmbeddableElement(container)) { if (container && isFrameLikeElement(container)) {
return true; return true;
} }
} }
@ -217,10 +229,16 @@ export const isEmbeddableOrLabel = (
}; };
export const createPlaceholderEmbeddableLabel = ( export const createPlaceholderEmbeddableLabel = (
element: ExcalidrawEmbeddableElement, element: ExcalidrawIframeLikeElement,
): ExcalidrawElement => { ): ExcalidrawElement => {
const text = let text: string;
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link; if (isIframeElement(element)) {
text = "IFrame element";
} else {
text =
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
}
const fontSize = Math.max( const fontSize = Math.max(
Math.min(element.width / 2, element.width / text.length), Math.min(element.width / 2, element.width / text.length),
element.width / 30, element.width / 30,

View File

@ -2,7 +2,6 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement,
} from "./types"; } from "./types";
import { isInvisiblySmallElement } from "./sizeHelpers"; import { isInvisiblySmallElement } from "./sizeHelpers";
import { isLinearElementType } from "./typeChecks"; import { isLinearElementType } from "./typeChecks";
@ -50,11 +49,7 @@ export {
getDragOffsetXY, getDragOffsetXY,
dragNewElement, dragNewElement,
} from "./dragElements"; } from "./dragElements";
export { export { isTextElement, isExcalidrawElement } from "./typeChecks";
isTextElement,
isExcalidrawElement,
isFrameElement,
} from "./typeChecks";
export { textWysiwyg } from "./textWysiwyg"; export { textWysiwyg } from "./textWysiwyg";
export { redrawTextBoundingBox } from "./textElement"; export { redrawTextBoundingBox } from "./textElement";
export { export {
@ -74,17 +69,10 @@ export const getVisibleElements = (elements: readonly ExcalidrawElement[]) =>
(el) => !el.isDeleted && !isInvisiblySmallElement(el), (el) => !el.isDeleted && !isInvisiblySmallElement(el),
) as readonly NonDeletedExcalidrawElement[]; ) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) => export const getNonDeletedElements = <T extends ExcalidrawElement>(
elements.filter( elements: readonly T[],
(element) => !element.isDeleted,
) as readonly NonDeletedExcalidrawElement[];
export const getNonDeletedFrames = (
frames: readonly ExcalidrawFrameElement[],
) => ) =>
frames.filter( elements.filter((element) => !element.isDeleted) as readonly NonDeleted<T>[];
(frame) => !frame.isDeleted,
) as readonly NonDeleted<ExcalidrawFrameElement>[];
export const isNonDeletedElement = <T extends ExcalidrawElement>( export const isNonDeletedElement = <T extends ExcalidrawElement>(
element: T, element: T,

View File

@ -14,6 +14,8 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawIframeElement,
} from "../element/types"; } from "../element/types";
import { import {
arrayToMap, arrayToMap,
@ -143,6 +145,16 @@ export const newEmbeddableElement = (
}; };
}; };
export const newIframeElement = (
opts: {
type: "iframe";
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawIframeElement> => {
return {
..._newElementBase<ExcalidrawIframeElement>("iframe", opts),
};
};
export const newFrameElement = ( export const newFrameElement = (
opts: { opts: {
name?: string; name?: string;
@ -160,6 +172,23 @@ export const newFrameElement = (
return frameElement; 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 */ /** computes element x/y offset based on textAlign/verticalAlign */
const getTextElementPositionOffsets = ( const getTextElementPositionOffsets = (
opts: { opts: {

View File

@ -27,7 +27,7 @@ import {
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
isFrameElement, isFrameLikeElement,
isFreeDrawElement, isFreeDrawElement,
isImageElement, isImageElement,
isLinearElement, isLinearElement,
@ -163,7 +163,7 @@ const rotateSingleElement = (
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2; const cy = (y1 + y2) / 2;
let angle: number; let angle: number;
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
angle = 0; angle = 0;
} else { } else {
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx); angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
@ -900,7 +900,7 @@ const rotateMultipleElements = (
} }
elements elements
.filter((element) => element.type !== "frame") .filter((element) => !isFrameLikeElement(element))
.forEach((element) => { .forEach((element) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const cx = (x1 + x2) / 2; const cx = (x1 + x2) / 2;

View File

@ -1,6 +1,7 @@
import { getFontString, arrayToMap, isTestEnv } from "../utils"; import { getFontString, arrayToMap, isTestEnv } from "../utils";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawElementType,
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawTextElement, ExcalidrawTextElement,
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
@ -867,7 +868,7 @@ const VALID_CONTAINER_TYPES = new Set([
]); ]);
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElement["type"]; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }) => VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (

View File

@ -8,7 +8,7 @@ import { Bounds, getElementAbsoluteCoords } from "./bounds";
import { rotate } from "../math"; import { rotate } from "../math";
import { InteractiveCanvasAppState, Zoom } from "../types"; import { InteractiveCanvasAppState, Zoom } from "../types";
import { isTextElement } from "."; import { isTextElement } from ".";
import { isFrameElement, isLinearElement } from "./typeChecks"; import { isFrameLikeElement, isLinearElement } from "./typeChecks";
import { DEFAULT_SPACING } from "../renderer/renderScene"; import { DEFAULT_SPACING } from "../renderer/renderScene";
export type TransformHandleDirection = export type TransformHandleDirection =
@ -257,7 +257,7 @@ export const getTransformHandles = (
} }
} else if (isTextElement(element)) { } else if (isTextElement(element)) {
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT; omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
} else if (isFrameElement(element)) { } else if (isFrameLikeElement(element)) {
omitSides = { omitSides = {
rotation: true, rotation: true,
}; };

View File

@ -1,5 +1,5 @@
import { ROUNDNESS } from "../constants"; import { ROUNDNESS } from "../constants";
import { AppState } from "../types"; import { ElementOrToolType } from "../types";
import { MarkNonNullable } from "../utility-types"; import { MarkNonNullable } from "../utility-types";
import { assertNever } from "../utils"; import { assertNever } from "../utils";
import { import {
@ -8,7 +8,6 @@ import {
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawGenericElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
ExcalidrawImageElement, ExcalidrawImageElement,
@ -16,21 +15,13 @@ import {
ExcalidrawTextContainer, ExcalidrawTextContainer,
ExcalidrawFrameElement, ExcalidrawFrameElement,
RoundnessType, RoundnessType,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
ExcalidrawIframeElement,
ExcalidrawIframeLikeElement,
ExcalidrawMagicFrameElement,
} from "./types"; } 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 = ( export const isInitializedImageElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is InitializedExcalidrawImageElement => { ): element is InitializedExcalidrawImageElement => {
@ -49,6 +40,20 @@ export const isEmbeddableElement = (
return !!element && element.type === "embeddable"; 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 = ( export const isTextElement = (
element: ExcalidrawElement | null, element: ExcalidrawElement | null,
): element is ExcalidrawTextElement => { ): element is ExcalidrawTextElement => {
@ -61,6 +66,21 @@ export const isFrameElement = (
return element != null && element.type === "frame"; 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 = ( export const isFreeDrawElement = (
element?: ExcalidrawElement | null, element?: ExcalidrawElement | null,
): element is ExcalidrawFreeDrawElement => { ): element is ExcalidrawFreeDrawElement => {
@ -68,7 +88,7 @@ export const isFreeDrawElement = (
}; };
export const isFreeDrawElementType = ( export const isFreeDrawElementType = (
elementType: ExcalidrawElement["type"], elementType: ExcalidrawElementType,
): boolean => { ): boolean => {
return elementType === "freedraw"; return elementType === "freedraw";
}; };
@ -86,7 +106,7 @@ export const isArrowElement = (
}; };
export const isLinearElementType = ( export const isLinearElementType = (
elementType: AppState["activeTool"]["type"], elementType: ElementOrToolType,
): boolean => { ): boolean => {
return ( return (
elementType === "arrow" || elementType === "line" // || elementType === "freedraw" elementType === "arrow" || elementType === "line" // || elementType === "freedraw"
@ -105,7 +125,7 @@ export const isBindingElement = (
}; };
export const isBindingElementType = ( export const isBindingElementType = (
elementType: AppState["activeTool"]["type"], elementType: ElementOrToolType,
): boolean => { ): boolean => {
return elementType === "arrow"; return elementType === "arrow";
}; };
@ -121,8 +141,10 @@ export const isBindableElement = (
element.type === "diamond" || element.type === "diamond" ||
element.type === "ellipse" || element.type === "ellipse" ||
element.type === "image" || element.type === "image" ||
element.type === "iframe" ||
element.type === "embeddable" || element.type === "embeddable" ||
element.type === "frame" || element.type === "frame" ||
element.type === "magicframe" ||
(element.type === "text" && !element.containerId)) (element.type === "text" && !element.containerId))
); );
}; };
@ -144,7 +166,7 @@ export const isTextBindableContainer = (
export const isExcalidrawElement = ( export const isExcalidrawElement = (
element: any, element: any,
): element is ExcalidrawElement => { ): element is ExcalidrawElement => {
const type: ExcalidrawElement["type"] | undefined = element?.type; const type: ExcalidrawElementType | undefined = element?.type;
if (!type) { if (!type) {
return false; return false;
} }
@ -152,12 +174,14 @@ export const isExcalidrawElement = (
case "text": case "text":
case "diamond": case "diamond":
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "ellipse": case "ellipse":
case "arrow": case "arrow":
case "freedraw": case "freedraw":
case "line": case "line":
case "frame": case "frame":
case "magicframe":
case "image": case "image":
case "selection": { case "selection": {
return true; return true;
@ -190,7 +214,7 @@ export const isBoundToContainer = (
}; };
export const isUsingAdaptiveRadius = (type: string) => export const isUsingAdaptiveRadius = (type: string) =>
type === "rectangle" || type === "embeddable"; type === "rectangle" || type === "embeddable" || type === "iframe";
export const isUsingProportionalRadius = (type: string) => export const isUsingProportionalRadius = (type: string) =>
type === "line" || type === "arrow" || type === "diamond"; type === "line" || type === "arrow" || type === "diamond";

View File

@ -7,6 +7,7 @@ import {
VERTICAL_ALIGN, VERTICAL_ALIGN,
} from "../constants"; } from "../constants";
import { MarkNonNullable, ValueOf } from "../utility-types"; import { MarkNonNullable, ValueOf } from "../utility-types";
import { MagicCacheData } from "../data/magic";
export type ChartType = "bar" | "line"; export type ChartType = "bar" | "line";
export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag"; export type FillStyle = "hachure" | "cross-hatch" | "solid" | "zigzag";
@ -97,6 +98,26 @@ export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
validated: boolean | null; 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 & export type ExcalidrawImageElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "image"; type: "image";
@ -117,6 +138,15 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
name: string | null; 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. * These are elements that don't have any additional properties.
*/ */
@ -138,6 +168,8 @@ export type ExcalidrawElement =
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement; | ExcalidrawEmbeddableElement;
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & { export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
@ -170,8 +202,10 @@ export type ExcalidrawBindableElement =
| ExcalidrawEllipseElement | ExcalidrawEllipseElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawIframeElement
| ExcalidrawEmbeddableElement | ExcalidrawEmbeddableElement
| ExcalidrawFrameElement; | ExcalidrawFrameElement
| ExcalidrawMagicFrameElement;
export type ExcalidrawTextContainer = export type ExcalidrawTextContainer =
| ExcalidrawRectangleElement | ExcalidrawRectangleElement
@ -217,3 +251,5 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
}>; }>;
export type FileId = string & { _brand: "FileId" }; export type FileId = string & { _brand: "FileId" };
export type ExcalidrawElementType = ExcalidrawElement["type"];

View File

@ -5,7 +5,7 @@ import {
} from "./element"; } from "./element";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
@ -18,11 +18,11 @@ import { arrayToMap } from "./utils";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types";
import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsWithinSelection, getSelectedElements } from "./scene";
import { isFrameElement } from "./element";
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
import { getElementLineSegments } from "./element/bounds"; import { getElementLineSegments } from "./element/bounds";
import { doLineSegmentsIntersect } from "./packages/utils"; import { doLineSegmentsIntersect } from "./packages/utils";
import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
// --------------------------- Frame State ------------------------------------ // --------------------------- Frame State ------------------------------------
export const bindElementsToFramesAfterDuplication = ( export const bindElementsToFramesAfterDuplication = (
@ -58,7 +58,7 @@ export const bindElementsToFramesAfterDuplication = (
export function isElementIntersectingFrame( export function isElementIntersectingFrame(
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) { ) {
const frameLineSegments = getElementLineSegments(frame); const frameLineSegments = getElementLineSegments(frame);
@ -75,20 +75,20 @@ export function isElementIntersectingFrame(
export const getElementsCompletelyInFrame = ( export const getElementsCompletelyInFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => ) =>
omitGroupsContainingFrames( omitGroupsContainingFrameLikes(
getElementsWithinSelection(elements, frame, false), getElementsWithinSelection(elements, frame, false),
).filter( ).filter(
(element) => (element) =>
(element.type !== "frame" && !element.frameId) || (!isFrameLikeElement(element) && !element.frameId) ||
element.frameId === frame.id, element.frameId === frame.id,
); );
export const isElementContainingFrame = ( export const isElementContainingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return getElementsWithinSelection(elements, element).some( return getElementsWithinSelection(elements, element).some(
(e) => e.id === frame.id, (e) => e.id === frame.id,
@ -97,12 +97,12 @@ export const isElementContainingFrame = (
export const getElementsIntersectingFrame = ( export const getElementsIntersectingFrame = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => elements.filter((element) => isElementIntersectingFrame(element, frame)); ) => elements.filter((element) => isElementIntersectingFrame(element, frame));
export const elementsAreInFrameBounds = ( export const elementsAreInFrameBounds = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const [selectionX1, selectionY1, selectionX2, selectionY2] = const [selectionX1, selectionY1, selectionX2, selectionY2] =
getElementAbsoluteCoords(frame); getElementAbsoluteCoords(frame);
@ -120,7 +120,7 @@ export const elementsAreInFrameBounds = (
export const elementOverlapsWithFrame = ( export const elementOverlapsWithFrame = (
element: ExcalidrawElement, element: ExcalidrawElement,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return ( return (
elementsAreInFrameBounds([element], frame) || elementsAreInFrameBounds([element], frame) ||
@ -134,7 +134,7 @@ export const isCursorInFrame = (
x: number; x: number;
y: number; y: number;
}, },
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => { ) => {
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
@ -148,7 +148,7 @@ export const isCursorInFrame = (
export const groupsAreAtLeastIntersectingTheFrame = ( export const groupsAreAtLeastIntersectingTheFrame = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[], groupIds: readonly string[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const elementsInGroup = groupIds.flatMap((groupId) => const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId), getElementsInGroup(elements, groupId),
@ -168,7 +168,7 @@ export const groupsAreAtLeastIntersectingTheFrame = (
export const groupsAreCompletelyOutOfFrame = ( export const groupsAreCompletelyOutOfFrame = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
groupIds: readonly string[], groupIds: readonly string[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const elementsInGroup = groupIds.flatMap((groupId) => const elementsInGroup = groupIds.flatMap((groupId) =>
getElementsInGroup(elements, groupId), getElementsInGroup(elements, groupId),
@ -192,14 +192,14 @@ export const groupsAreCompletelyOutOfFrame = (
/** /**
* Returns a map of frameId to frame elements. Includes empty frames. * 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< const frameElementsMap = new Map<
ExcalidrawElement["id"], ExcalidrawElement["id"],
ExcalidrawElement[] ExcalidrawElement[]
>(); >();
for (const element of elements) { 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)) { if (frameId && !frameElementsMap.has(frameId)) {
frameElementsMap.set(frameId, getFrameChildren(elements, frameId)); frameElementsMap.set(frameId, getFrameChildren(elements, frameId));
} }
@ -213,12 +213,12 @@ export const getFrameChildren = (
frameId: string, frameId: string,
) => allElements.filter((element) => element.frameId === frameId); ) => allElements.filter((element) => element.frameId === frameId);
export const getFrameElements = ( export const getFrameLikeElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
): ExcalidrawFrameElement[] => { ): ExcalidrawFrameLikeElement[] => {
return allElements.filter((element) => return allElements.filter((element): element is ExcalidrawFrameLikeElement =>
isFrameElement(element), isFrameLikeElement(element),
) as ExcalidrawFrameElement[]; );
}; };
/** /**
@ -232,7 +232,7 @@ export const getFrameElements = (
export const getRootElements = ( export const getRootElements = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
) => { ) => {
const frameElements = arrayToMap(getFrameElements(allElements)); const frameElements = arrayToMap(getFrameLikeElements(allElements));
return allElements.filter( return allElements.filter(
(element) => (element) =>
frameElements.has(element.id) || frameElements.has(element.id) ||
@ -243,7 +243,7 @@ export const getRootElements = (
export const getElementsInResizingFrame = ( export const getElementsInResizingFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
const prevElementsInFrame = getFrameChildren(allElements, frame.id); const prevElementsInFrame = getFrameChildren(allElements, frame.id);
@ -336,9 +336,9 @@ export const getElementsInResizingFrame = (
export const getElementsInNewFrame = ( export const getElementsInNewFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
return omitGroupsContainingFrames( return omitGroupsContainingFrameLikes(
allElements, allElements,
getElementsCompletelyInFrame(allElements, frame), getElementsCompletelyInFrame(allElements, frame),
); );
@ -356,12 +356,12 @@ export const getContainingFrame = (
if (element.frameId) { if (element.frameId) {
if (elementsMap) { if (elementsMap) {
return (elementsMap.get(element.frameId) || return (elementsMap.get(element.frameId) ||
null) as null | ExcalidrawFrameElement; null) as null | ExcalidrawFrameLikeElement;
} }
return ( return (
(Scene.getScene(element)?.getElement( (Scene.getScene(element)?.getElement(
element.frameId, element.frameId,
) as ExcalidrawFrameElement) || null ) as ExcalidrawFrameLikeElement) || null
); );
} }
return null; return null;
@ -377,7 +377,7 @@ export const getContainingFrame = (
export const addElementsToFrame = ( export const addElementsToFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
elementsToAdd: NonDeletedExcalidrawElement[], elementsToAdd: NonDeletedExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
) => { ) => {
const { currTargetFrameChildrenMap } = allElements.reduce( const { currTargetFrameChildrenMap } = allElements.reduce(
(acc, element, index) => { (acc, element, index) => {
@ -397,7 +397,7 @@ export const addElementsToFrame = (
// - add bound text elements if not already in the array // - add bound text elements if not already in the array
// - filter out elements that are already in the frame // - filter out elements that are already in the frame
for (const element of omitGroupsContainingFrames( for (const element of omitGroupsContainingFrameLikes(
allElements, allElements,
elementsToAdd, elementsToAdd,
)) { )) {
@ -438,7 +438,7 @@ export const removeElementsFromFrame = (
>(); >();
const toRemoveElementsByFrame = new Map< const toRemoveElementsByFrame = new Map<
ExcalidrawFrameElement["id"], ExcalidrawFrameLikeElement["id"],
ExcalidrawElement[] ExcalidrawElement[]
>(); >();
@ -474,7 +474,7 @@ export const removeElementsFromFrame = (
export const removeAllElementsFromFrame = ( export const removeAllElementsFromFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
) => { ) => {
const elementsInFrame = getFrameChildren(allElements, frame.id); const elementsInFrame = getFrameChildren(allElements, frame.id);
@ -484,7 +484,7 @@ export const removeAllElementsFromFrame = (
export const replaceAllElementsInFrame = ( export const replaceAllElementsInFrame = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
nextElementsInFrame: ExcalidrawElement[], nextElementsInFrame: ExcalidrawElement[],
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
appState: AppState, appState: AppState,
) => { ) => {
return addElementsToFrame( return addElementsToFrame(
@ -524,7 +524,7 @@ export const updateFrameMembershipOfSelectedElements = (
elementsToFilter.forEach((element) => { elementsToFilter.forEach((element) => {
if ( if (
element.frameId && element.frameId &&
!isFrameElement(element) && !isFrameLikeElement(element) &&
!isElementInFrame(element, allElements, appState) !isElementInFrame(element, allElements, appState)
) { ) {
elementsToRemove.add(element); elementsToRemove.add(element);
@ -540,7 +540,7 @@ export const updateFrameMembershipOfSelectedElements = (
* filters out elements that are inside groups that contain a frame element * filters out elements that are inside groups that contain a frame element
* anywhere in the group tree * anywhere in the group tree
*/ */
export const omitGroupsContainingFrames = ( export const omitGroupsContainingFrameLikes = (
allElements: ExcalidrawElementsIncludingDeleted, allElements: ExcalidrawElementsIncludingDeleted,
/** subset of elements you want to filter. Optional perf optimization so we /** subset of elements you want to filter. Optional perf optimization so we
* don't have to filter all elements unnecessarily * don't have to filter all elements unnecessarily
@ -558,7 +558,9 @@ export const omitGroupsContainingFrames = (
const rejectedGroupIds = new Set<string>(); const rejectedGroupIds = new Set<string>();
for (const groupId of uniqueGroupIds) { for (const groupId of uniqueGroupIds) {
if ( if (
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el)) getElementsInGroup(allElements, groupId).some((el) =>
isFrameLikeElement(el),
)
) { ) {
rejectedGroupIds.add(groupId); rejectedGroupIds.add(groupId);
} }
@ -636,7 +638,7 @@ export const isElementInFrame = (
} }
for (const elementInGroup of allElementsInGroup) { for (const elementInGroup of allElementsInGroup) {
if (isFrameElement(elementInGroup)) { if (isFrameLikeElement(elementInGroup)) {
return false; return false;
} }
} }
@ -650,3 +652,15 @@ export const isElementInFrame = (
return false; 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}`;
};

View File

@ -11,6 +11,8 @@
"copyAsPng": "Copy to clipboard as PNG", "copyAsPng": "Copy to clipboard as PNG",
"copyAsSvg": "Copy to clipboard as SVG", "copyAsSvg": "Copy to clipboard as SVG",
"copyText": "Copy to clipboard as text", "copyText": "Copy to clipboard as text",
"copySource": "Copy source to clipboard",
"convertToCode": "Convert to code",
"bringForward": "Bring forward", "bringForward": "Bring forward",
"sendToBack": "Send to back", "sendToBack": "Send to back",
"bringToFront": "Bring to front", "bringToFront": "Bring to front",
@ -218,6 +220,7 @@
}, },
"libraryElementTypeError": { "libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.", "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!" "image": "Support for adding images to the library coming soon!"
}, },
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).", "asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
@ -240,11 +243,13 @@
"link": "Add/ Update link for a selected shape", "link": "Add/ Update link for a selected shape",
"eraser": "Eraser", "eraser": "Eraser",
"frame": "Frame tool", "frame": "Frame tool",
"magicframe": "Wireframe to code",
"embeddable": "Web Embed", "embeddable": "Web Embed",
"laser": "Laser pointer", "laser": "Laser pointer",
"hand": "Hand (panning tool)", "hand": "Hand (panning tool)",
"extraTools": "More tools", "extraTools": "More tools",
"mermaidToExcalidraw": "Mermaid to Excalidraw" "mermaidToExcalidraw": "Mermaid to Excalidraw",
"magicSettings": "AI settings"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",

View File

@ -44,6 +44,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
children, children,
validateEmbeddable, validateEmbeddable,
renderEmbeddable, renderEmbeddable,
aiEnabled,
} = props; } = props;
const canvasActions = props.UIOptions?.canvasActions; const canvasActions = props.UIOptions?.canvasActions;
@ -122,6 +123,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onScrollChange={onScrollChange} onScrollChange={onScrollChange}
validateEmbeddable={validateEmbeddable} validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable} renderEmbeddable={renderEmbeddable}
aiEnabled={aiEnabled !== false}
> >
{children} {children}
</App> </App>

View File

@ -6,7 +6,7 @@ import { getDefaultAppState } from "../appState";
import { AppState, BinaryFiles } from "../types"; import { AppState, BinaryFiles } from "../types";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
NonDeleted, NonDeleted,
} from "../element/types"; } from "../element/types";
import { restore } from "../data/restore"; import { restore } from "../data/restore";
@ -26,7 +26,7 @@ type ExportOpts = {
appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>; appState?: Partial<Omit<AppState, "offsetTop" | "offsetLeft">>;
files: BinaryFiles | null; files: BinaryFiles | null;
maxWidthOrHeight?: number; maxWidthOrHeight?: number;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
getDimensions?: ( getDimensions?: (
width: number, width: number,
height: number, height: number,

View File

@ -13,6 +13,7 @@ import {
isInitializedImageElement, isInitializedImageElement,
isArrowElement, isArrowElement,
hasBoundTextElement, hasBoundTextElement,
isMagicFrameElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { getElementAbsoluteCoords } from "../element/bounds"; import { getElementAbsoluteCoords } from "../element/bounds";
import type { RoughCanvas } from "roughjs/bin/canvas"; import type { RoughCanvas } from "roughjs/bin/canvas";
@ -272,6 +273,7 @@ const drawElementOnCanvas = (
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
@ -594,6 +596,7 @@ export const renderElement = (
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
) => { ) => {
switch (element.type) { switch (element.type) {
case "magicframe":
case "frame": { case "frame": {
if (appState.frameRendering.enabled && appState.frameRendering.outline) { if (appState.frameRendering.enabled && appState.frameRendering.outline) {
context.save(); context.save();
@ -606,6 +609,12 @@ export const renderElement = (
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor; 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) { if (FRAME_STYLE.radius && context.roundRect) {
context.beginPath(); context.beginPath();
context.roundRect( context.roundRect(
@ -666,6 +675,7 @@ export const renderElement = (
case "arrow": case "arrow":
case "image": case "image":
case "text": case "text":
case "iframe":
case "embeddable": { case "embeddable": {
// TODO investigate if we can do this in situ. Right now we need to call // TODO investigate if we can do this in situ. Right now we need to call
// beforehand because math helpers (such as getElementAbsoluteCoords) // beforehand because math helpers (such as getElementAbsoluteCoords)
@ -951,6 +961,7 @@ export const renderElementToSvg = (
addToRoot(g || node, element); addToRoot(g || node, element);
break; break;
} }
case "iframe":
case "embeddable": { case "embeddable": {
// render placeholder rectangle // render placeholder rectangle
const shape = ShapeCache.generateElementShape(element, true); const shape = ShapeCache.generateElementShape(element, true);
@ -1252,7 +1263,8 @@ export const renderElementToSvg = (
break; break;
} }
// frames are not rendered and only acts as a container // frames are not rendered and only acts as a container
case "frame": { case "frame":
case "magicframe": {
if ( if (
renderConfig.frameRendering.enabled && renderConfig.frameRendering.enabled &&
renderConfig.frameRendering.outline renderConfig.frameRendering.outline

View File

@ -16,7 +16,7 @@ import {
NonDeleted, NonDeleted,
GroupId, GroupId,
ExcalidrawBindableElement, ExcalidrawBindableElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
} from "../element/types"; } from "../element/types";
import { import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
@ -70,11 +70,12 @@ import {
import { renderSnaps } from "./renderSnaps"; import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,
isFrameElement, isFrameLikeElement,
isIframeLikeElement,
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { import {
isEmbeddableOrLabel, isIframeLikeOrItsLabel,
createPlaceholderEmbeddableLabel, createPlaceholderEmbeddableLabel,
} from "../element/embeddable"; } from "../element/embeddable";
import { import {
@ -362,7 +363,7 @@ const renderLinearElementPointHighlight = (
}; };
const frameClip = ( const frameClip = (
frame: ExcalidrawFrameElement, frame: ExcalidrawFrameLikeElement,
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: StaticCanvasRenderConfig, renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState, appState: StaticCanvasAppState,
@ -515,7 +516,7 @@ const _renderInteractiveScene = ({
} }
const isFrameSelected = selectedElements.some((element) => 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 // 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 // Paint visible elements
visibleElements visibleElements
.filter((el) => !isEmbeddableOrLabel(el)) .filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
try { try {
const frameId = element.frameId || appState.frameToHighlight?.id; const frameId = element.frameId || appState.frameToHighlight?.id;
@ -996,15 +997,16 @@ const _renderStaticScene = ({
// render embeddables on top // render embeddables on top
visibleElements visibleElements
.filter((el) => isEmbeddableOrLabel(el)) .filter((el) => isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
try { try {
const render = () => { const render = () => {
renderElement(element, rc, context, renderConfig, appState); renderElement(element, rc, context, renderConfig, appState);
if ( if (
isEmbeddableElement(element) && isIframeLikeElement(element) &&
(isExporting || !element.validated) && (isExporting ||
(isEmbeddableElement(element) && !element.validated)) &&
element.width && element.width &&
element.height element.height
) { ) {
@ -1242,8 +1244,10 @@ const renderBindingHighlightForBindableElement = (
case "rectangle": case "rectangle":
case "text": case "text":
case "image": case "image":
case "iframe":
case "embeddable": case "embeddable":
case "frame": case "frame":
case "magicframe":
strokeRectWithRotation( strokeRectWithRotation(
context, context,
x1 - padding, x1 - padding,
@ -1284,7 +1288,7 @@ const renderBindingHighlightForBindableElement = (
const renderFrameHighlight = ( const renderFrameHighlight = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
frame: NonDeleted<ExcalidrawFrameElement>, frame: NonDeleted<ExcalidrawFrameLikeElement>,
) => { ) => {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
const width = x2 - x1; const width = x2 - x1;
@ -1469,7 +1473,7 @@ export const renderSceneToSvg = (
}; };
// render elements // render elements
elements elements
.filter((el) => !isEmbeddableOrLabel(el)) .filter((el) => !isIframeLikeOrItsLabel(el))
.forEach((element) => { .forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {
@ -1490,7 +1494,7 @@ export const renderSceneToSvg = (
// render embeddables on top // render embeddables on top
elements elements
.filter((el) => isEmbeddableElement(el)) .filter((el) => isIframeLikeElement(el))
.forEach((element) => { .forEach((element) => {
if (!element.isDeleted) { if (!element.isDeleted) {
try { try {

View File

@ -2,15 +2,11 @@ import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
NonDeleted, NonDeleted,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
} from "../element/types"; } from "../element/types";
import { import { getNonDeletedElements, isNonDeletedElement } from "../element";
getNonDeletedElements,
getNonDeletedFrames,
isNonDeletedElement,
} from "../element";
import { LinearElementEditor } from "../element/linearElementEditor"; import { LinearElementEditor } from "../element/linearElementEditor";
import { isFrameElement } from "../element/typeChecks"; import { isFrameLikeElement } from "../element/typeChecks";
import { getSelectedElements } from "./selection"; import { getSelectedElements } from "./selection";
import { AppState } from "../types"; import { AppState } from "../types";
import { Assert, SameType } from "../utility-types"; import { Assert, SameType } from "../utility-types";
@ -107,8 +103,9 @@ class Scene {
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = []; private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
private elements: readonly ExcalidrawElement[] = []; private elements: readonly ExcalidrawElement[] = [];
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = []; private nonDeletedFramesLikes: readonly NonDeleted<ExcalidrawFrameLikeElement>[] =
private frames: readonly ExcalidrawFrameElement[] = []; [];
private frames: readonly ExcalidrawFrameLikeElement[] = [];
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>(); private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
private selectedElementsCache: { private selectedElementsCache: {
selectedElementIds: AppState["selectedElementIds"] | null; selectedElementIds: AppState["selectedElementIds"] | null;
@ -179,8 +176,8 @@ class Scene {
return selectedElements; return selectedElements;
} }
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] { getNonDeletedFramesLikes(): readonly NonDeleted<ExcalidrawFrameLikeElement>[] {
return this.nonDeletedFrames; return this.nonDeletedFramesLikes;
} }
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null { getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
@ -235,18 +232,18 @@ class Scene {
mapElementIds = true, mapElementIds = true,
) { ) {
this.elements = nextElements; this.elements = nextElements;
const nextFrames: ExcalidrawFrameElement[] = []; const nextFrameLikes: ExcalidrawFrameLikeElement[] = [];
this.elementsMap.clear(); this.elementsMap.clear();
nextElements.forEach((element) => { nextElements.forEach((element) => {
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
nextFrames.push(element); nextFrameLikes.push(element);
} }
this.elementsMap.set(element.id, element); this.elementsMap.set(element.id, element);
Scene.mapElementToScene(element, this); Scene.mapElementToScene(element, this);
}); });
this.nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = getNonDeletedElements(this.elements);
this.frames = nextFrames; this.frames = nextFrameLikes;
this.nonDeletedFrames = getNonDeletedFrames(this.frames); this.nonDeletedFramesLikes = getNonDeletedElements(this.frames);
this.informMutation(); this.informMutation();
} }
@ -277,7 +274,7 @@ class Scene {
destroy() { destroy() {
this.nonDeletedElements = []; this.nonDeletedElements = [];
this.elements = []; this.elements = [];
this.nonDeletedFrames = []; this.nonDeletedFramesLikes = [];
this.frames = []; this.frames = [];
this.elementsMap.clear(); this.elementsMap.clear();
this.selectedElementsCache.selectedElementIds = null; this.selectedElementsCache.selectedElementIds = null;

View File

@ -14,7 +14,12 @@ import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils"; import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants"; import { ROUGHNESS } from "../constants";
import { isLinearElement } from "../element/typeChecks"; import {
isEmbeddableElement,
isIframeElement,
isIframeLikeElement,
isLinearElement,
} from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -78,6 +83,7 @@ export const generateRoughOptions = (
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": case "embeddable":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
@ -109,13 +115,13 @@ export const generateRoughOptions = (
} }
}; };
const modifyEmbeddableForRoughOptions = ( const modifyIframeLikeForRoughOptions = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
isExporting: boolean, isExporting: boolean,
) => { ) => {
if ( if (
element.type === "embeddable" && isIframeLikeElement(element) &&
(isExporting || !element.validated) && (isExporting || (isEmbeddableElement(element) && !element.validated)) &&
isTransparent(element.backgroundColor) && isTransparent(element.backgroundColor) &&
isTransparent(element.strokeColor) isTransparent(element.strokeColor)
) { ) {
@ -125,6 +131,16 @@ const modifyEmbeddableForRoughOptions = (
backgroundColor: "#d3d3d3", backgroundColor: "#d3d3d3",
fillStyle: "solid", fillStyle: "solid",
} as const; } as const;
} else if (isIframeElement(element)) {
return {
...element,
strokeColor: isTransparent(element.strokeColor)
? "#000000"
: element.strokeColor,
backgroundColor: isTransparent(element.backgroundColor)
? "#f4f4f6"
: element.backgroundColor,
};
} }
return element; return element;
}; };
@ -143,6 +159,7 @@ export const _generateElementShape = (
): Drawable | Drawable[] | null => { ): Drawable | Drawable[] | null => {
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe":
case "embeddable": { case "embeddable": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
// this is for rendering the stroke/bg of the embeddable, especially // this is for rendering the stroke/bg of the embeddable, especially
@ -159,7 +176,7 @@ export const _generateElementShape = (
h - r h - r
} L 0 ${r} Q 0 0, ${r} 0`, } L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions( generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting), modifyIframeLikeForRoughOptions(element, isExporting),
true, true,
), ),
); );
@ -170,7 +187,7 @@ export const _generateElementShape = (
element.width, element.width,
element.height, element.height,
generateRoughOptions( generateRoughOptions(
modifyEmbeddableForRoughOptions(element, isExporting), modifyIframeLikeForRoughOptions(element, isExporting),
false, false,
), ),
); );
@ -373,6 +390,7 @@ export const _generateElementShape = (
return shape; return shape;
} }
case "frame": case "frame":
case "magicframe":
case "text": case "text":
case "image": { case "image": {
const shape: ElementShapes[typeof element.type] = null; const shape: ElementShapes[typeof element.type] = null;

View File

@ -1,22 +1,25 @@
import { isEmbeddableElement } from "../element/typeChecks"; import { isIframeElement } from "../element/typeChecks";
import { import {
ExcalidrawEmbeddableElement, ExcalidrawIframeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { ElementOrToolType } from "../types";
export const hasBackground = (type: string) => export const hasBackground = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "line" || type === "line" ||
type === "freedraw"; type === "freedraw";
export const hasStrokeColor = (type: string) => export const hasStrokeColor = (type: ElementOrToolType) =>
type !== "image" && type !== "frame"; type !== "image" && type !== "frame" && type !== "magicframe";
export const hasStrokeWidth = (type: string) => export const hasStrokeWidth = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
@ -24,22 +27,24 @@ export const hasStrokeWidth = (type: string) =>
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const hasStrokeStyle = (type: string) => export const hasStrokeStyle = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "ellipse" || type === "ellipse" ||
type === "diamond" || type === "diamond" ||
type === "arrow" || type === "arrow" ||
type === "line"; type === "line";
export const canChangeRoundness = (type: string) => export const canChangeRoundness = (type: ElementOrToolType) =>
type === "rectangle" || type === "rectangle" ||
type === "iframe" ||
type === "embeddable" || type === "embeddable" ||
type === "arrow" || type === "arrow" ||
type === "line" || type === "line" ||
type === "diamond"; type === "diamond";
export const canHaveArrowheads = (type: string) => type === "arrow"; export const canHaveArrowheads = (type: ElementOrToolType) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@ -67,7 +72,7 @@ export const getElementsAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,
) => { ) => {
const embeddables: ExcalidrawEmbeddableElement[] = []; const iframeLikes: ExcalidrawIframeElement[] = [];
// The parameter elements comes ordered from lower z-index to higher. // The parameter elements comes ordered from lower z-index to higher.
// We want to preserve that order on the returned array. // We want to preserve that order on the returned array.
// Exception being embeddables which should be on top of everything else in // 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 elsAtPos = elements.filter((element) => {
const hit = !element.isDeleted && isAtPositionFn(element); const hit = !element.isDeleted && isAtPositionFn(element);
if (hit) { if (hit) {
if (isEmbeddableElement(element)) { if (isIframeElement(element)) {
embeddables.push(element); iframeLikes.push(element);
return false; return false;
} }
return true; return true;
} }
return false; return false;
}); });
return elsAtPos.concat(embeddables); return elsAtPos.concat(iframeLikes);
}; };

View File

@ -1,7 +1,7 @@
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawFrameElement, ExcalidrawFrameLikeElement,
ExcalidrawTextElement, ExcalidrawTextElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
@ -27,11 +27,16 @@ import {
updateImageCache, updateImageCache,
} from "../element/image"; } from "../element/image";
import { elementsOverlappingBBox } from "../packages/withinBounds"; import { elementsOverlappingBBox } from "../packages/withinBounds";
import { getFrameElements, getRootElements } from "../frame"; import {
import { isFrameElement, newTextElement } from "../element"; getFrameLikeElements,
getFrameLikeTitle,
getRootElements,
} from "../frame";
import { newTextElement } from "../element";
import { Mutable } from "../utility-types"; import { Mutable } from "../utility-types";
import { newElementWith } from "../element/mutateElement"; import { newElementWith } from "../element/mutateElement";
import Scene from "./Scene"; import Scene from "./Scene";
import { isFrameElement, isFrameLikeElement } from "../element/typeChecks";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@ -100,10 +105,15 @@ const addFrameLabelsAsTextElements = (
opts: Pick<AppState, "exportWithDarkMode">, opts: Pick<AppState, "exportWithDarkMode">,
) => { ) => {
const nextElements: NonDeletedExcalidrawElement[] = []; const nextElements: NonDeletedExcalidrawElement[] = [];
let frameIdx = 0; let frameIndex = 0;
let magicFrameIndex = 0;
for (const element of elements) { for (const element of elements) {
if (isFrameElement(element)) { if (isFrameLikeElement(element)) {
frameIdx++; if (isFrameElement(element)) {
frameIndex++;
} else {
magicFrameIndex++;
}
let textElement: Mutable<ExcalidrawTextElement> = newTextElement({ let textElement: Mutable<ExcalidrawTextElement> = newTextElement({
x: element.x, x: element.x,
y: element.y - FRAME_STYLE.nameOffsetY, y: element.y - FRAME_STYLE.nameOffsetY,
@ -114,7 +124,10 @@ const addFrameLabelsAsTextElements = (
strokeColor: opts.exportWithDarkMode strokeColor: opts.exportWithDarkMode
? FRAME_STYLE.nameColorDarkTheme ? FRAME_STYLE.nameColorDarkTheme
: FRAME_STYLE.nameColorLightTheme, : FRAME_STYLE.nameColorLightTheme,
text: element.name || `Frame ${frameIdx}`, text: getFrameLikeTitle(
element,
isFrameElement(element) ? frameIndex : magicFrameIndex,
),
}); });
textElement.y -= textElement.height; textElement.y -= textElement.height;
@ -129,7 +142,7 @@ const addFrameLabelsAsTextElements = (
}; };
const getFrameRenderingConfig = ( const getFrameRenderingConfig = (
exportingFrame: ExcalidrawFrameElement | null, exportingFrame: ExcalidrawFrameLikeElement | null,
frameRendering: AppState["frameRendering"] | null, frameRendering: AppState["frameRendering"] | null,
): AppState["frameRendering"] => { ): AppState["frameRendering"] => {
frameRendering = frameRendering || getDefaultAppState().frameRendering; frameRendering = frameRendering || getDefaultAppState().frameRendering;
@ -148,7 +161,7 @@ const prepareElementsForRender = ({
exportWithDarkMode, exportWithDarkMode,
}: { }: {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];
exportingFrame: ExcalidrawFrameElement | null | undefined; exportingFrame: ExcalidrawFrameLikeElement | null | undefined;
frameRendering: AppState["frameRendering"]; frameRendering: AppState["frameRendering"];
exportWithDarkMode: AppState["exportWithDarkMode"]; exportWithDarkMode: AppState["exportWithDarkMode"];
}) => { }) => {
@ -184,7 +197,7 @@ export const exportToCanvas = async (
exportBackground: boolean; exportBackground: boolean;
exportPadding?: number; exportPadding?: number;
viewBackgroundColor: string; viewBackgroundColor: string;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
}, },
createCanvas: ( createCanvas: (
width: number, width: number,
@ -274,7 +287,7 @@ export const exportToSvg = async (
files: BinaryFiles | null, files: BinaryFiles | null,
opts?: { opts?: {
renderEmbeddables?: boolean; renderEmbeddables?: boolean;
exportingFrame?: ExcalidrawFrameElement | null; exportingFrame?: ExcalidrawFrameLikeElement | null;
}, },
): Promise<SVGSVGElement> => { ): Promise<SVGSVGElement> => {
const tempScene = __createSceneForElementsHack__(elements); const tempScene = __createSceneForElementsHack__(elements);
@ -360,7 +373,7 @@ export const exportToSvg = async (
const offsetX = -minX + exportPadding; const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding; const offsetY = -minY + exportPadding;
const frameElements = getFrameElements(elements); const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = ""; let exportingFrameClipPath = "";
for (const frame of frameElements) { for (const frame of frameElements) {

View File

@ -4,7 +4,7 @@ import {
} from "../element/types"; } from "../element/types";
import { getElementAbsoluteCoords, getElementBounds } from "../element"; import { getElementAbsoluteCoords, getElementBounds } from "../element";
import { AppState, InteractiveCanvasAppState } from "../types"; import { AppState, InteractiveCanvasAppState } from "../types";
import { isBoundToContainer } from "../element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
getContainingFrame, getContainingFrame,
@ -27,7 +27,7 @@ export const excludeElementsInFramesFromSelection = <
const framesInSelection = new Set<T["id"]>(); const framesInSelection = new Set<T["id"]>();
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (element.type === "frame") { if (isFrameLikeElement(element)) {
framesInSelection.add(element.id); framesInSelection.add(element.id);
} }
}); });
@ -190,7 +190,7 @@ export const getSelectedElements = (
if (opts?.includeElementsInFrames) { if (opts?.includeElementsInFrames) {
const elementsToInclude: ExcalidrawElement[] = []; const elementsToInclude: ExcalidrawElement[] = [];
selectedElements.forEach((element) => { selectedElements.forEach((element) => {
if (element.type === "frame") { if (isFrameLikeElement(element)) {
getFrameChildren(elements, element.id).forEach((e) => getFrameChildren(elements, element.id).forEach((e) =>
elementsToInclude.push(e), elementsToInclude.push(e),
); );

View File

@ -98,6 +98,7 @@ export type ElementShapes = {
rectangle: Drawable; rectangle: Drawable;
ellipse: Drawable; ellipse: Drawable;
diamond: Drawable; diamond: Drawable;
iframe: Drawable;
embeddable: Drawable; embeddable: Drawable;
freedraw: Drawable | null; freedraw: Drawable | null;
arrow: Drawable[]; arrow: Drawable[];
@ -105,4 +106,5 @@ export type ElementShapes = {
text: null; text: null;
image: null; image: null;
frame: null; frame: null;
magicframe: null;
}; };

View File

@ -83,14 +83,6 @@ export const SHAPES = [
numericKey: KEYS["0"], numericKey: KEYS["0"],
fillable: false, 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; ] as const;
export const findShapeByKey = (key: string) => { export const findShapeByKey = (key: string) => {

View File

@ -1,3 +1,4 @@
import { TOOL_TYPE } from "./constants";
import { import {
Bounds, Bounds,
getCommonBounds, getCommonBounds,
@ -5,7 +6,7 @@ import {
getElementAbsoluteCoords, getElementAbsoluteCoords,
} from "./element/bounds"; } from "./element/bounds";
import { MaybeTransformHandleType } from "./element/transformHandles"; import { MaybeTransformHandleType } from "./element/transformHandles";
import { isBoundToContainer, isFrameElement } from "./element/typeChecks"; import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks";
import { import {
ExcalidrawElement, ExcalidrawElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
@ -262,7 +263,7 @@ const getReferenceElements = (
appState: AppState, appState: AppState,
) => { ) => {
const selectedFrames = selectedElements const selectedFrames = selectedElements
.filter((element) => isFrameElement(element)) .filter((element) => isFrameLikeElement(element))
.map((frame) => frame.id); .map((frame) => frame.id);
return getVisibleAndNonSelectedElements( return getVisibleAndNonSelectedElements(
@ -1352,10 +1353,11 @@ export const isActiveToolNonLinearSnappable = (
activeToolType: AppState["activeTool"]["type"], activeToolType: AppState["activeTool"]["type"],
) => { ) => {
return ( return (
activeToolType === "rectangle" || activeToolType === TOOL_TYPE.rectangle ||
activeToolType === "ellipse" || activeToolType === TOOL_TYPE.ellipse ||
activeToolType === "diamond" || activeToolType === TOOL_TYPE.diamond ||
activeToolType === "frame" || activeToolType === TOOL_TYPE.frame ||
activeToolType === "image" activeToolType === TOOL_TYPE.magicframe ||
activeToolType === TOOL_TYPE.image
); );
}; };

View File

@ -7,6 +7,8 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawElementType,
ExcalidrawMagicFrameElement,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@ -20,7 +22,9 @@ import {
newEmbeddableElement, newEmbeddableElement,
newFrameElement, newFrameElement,
newFreeDrawElement, newFreeDrawElement,
newIframeElement,
newImageElement, newImageElement,
newMagicFrameElement,
} from "../../element/newElement"; } from "../../element/newElement";
import { Point } from "../../types"; import { Point } from "../../types";
import { getSelectedElements } from "../../scene/selection"; import { getSelectedElements } from "../../scene/selection";
@ -74,7 +78,7 @@ export class API {
}; };
static createElement = < static createElement = <
T extends Exclude<ExcalidrawElement["type"], "selection"> = "rectangle", T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
>({ >({
// @ts-ignore // @ts-ignore
type = "rectangle", type = "rectangle",
@ -139,6 +143,8 @@ export class API {
? ExcalidrawImageElement ? ExcalidrawImageElement
: T extends "frame" : T extends "frame"
? ExcalidrawFrameElement ? ExcalidrawFrameElement
: T extends "magicframe"
? ExcalidrawMagicFrameElement
: ExcalidrawGenericElement => { : ExcalidrawGenericElement => {
let element: Mutable<ExcalidrawElement> = null!; let element: Mutable<ExcalidrawElement> = null!;
@ -202,6 +208,12 @@ export class API {
validated: null, validated: null,
}); });
break; break;
case "iframe":
element = newIframeElement({
type: "iframe",
...base,
});
break;
case "text": case "text":
const fontSize = rest.fontSize ?? appState.currentItemFontSize; const fontSize = rest.fontSize ?? appState.currentItemFontSize;
const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily; const fontFamily = rest.fontFamily ?? appState.currentItemFontFamily;
@ -253,6 +265,9 @@ export class API {
case "frame": case "frame":
element = newFrameElement({ ...base, width, height }); element = newFrameElement({ ...base, width, height });
break; break;
case "magicframe":
element = newMagicFrameElement({ ...base, width, height });
break;
default: default:
assertNever( assertNever(
type, type,

View File

@ -1,4 +1,4 @@
import type { Point } from "../../types"; import type { Point, ToolType } from "../../types";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@ -20,15 +20,14 @@ import {
type TransformHandleDirection, type TransformHandleDirection,
} from "../../element/transformHandles"; } from "../../element/transformHandles";
import { KEYS } from "../../keys"; import { KEYS } from "../../keys";
import { type ToolName } from "../queries/toolQueries";
import { fireEvent, GlobalTestState, screen } from "../test-utils"; import { fireEvent, GlobalTestState, screen } from "../test-utils";
import { mutateElement } from "../../element/mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { API } from "./api"; import { API } from "./api";
import { import {
isFrameElement,
isLinearElement, isLinearElement,
isFreeDrawElement, isFreeDrawElement,
isTextElement, isTextElement,
isFrameLikeElement,
} from "../../element/typeChecks"; } from "../../element/typeChecks";
import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math"; import { rotatePoint } from "../../math";
@ -290,7 +289,7 @@ const transform = (
]; ];
} else { } else {
const [x1, y1, x2, y2] = getCommonBounds(elements); const [x1, y1, x2, y2] = getCommonBounds(elements);
const isFrameSelected = elements.some(isFrameElement); const isFrameSelected = elements.some(isFrameLikeElement);
const transformHandles = getTransformHandlesFromCoords( const transformHandles = getTransformHandlesFromCoords(
[x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2], [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
0, 0,
@ -345,7 +344,7 @@ const proxy = <T extends ExcalidrawElement>(
}; };
/** Tools that can be used to draw shapes */ /** Tools that can be used to draw shapes */
type DrawingToolName = Exclude<ToolName, "lock" | "selection" | "eraser">; type DrawingToolName = Exclude<ToolType, "lock" | "selection" | "eraser">;
type Element<T extends DrawingToolName> = T extends "line" | "freedraw" type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
? ExcalidrawLinearElement ? ExcalidrawLinearElement
@ -362,7 +361,7 @@ type Element<T extends DrawingToolName> = T extends "line" | "freedraw"
: ExcalidrawElement; : ExcalidrawElement;
export class UI { export class UI {
static clickTool = (toolName: ToolName) => { static clickTool = (toolName: ToolType | "lock") => {
fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName)); fireEvent.click(GlobalTestState.renderResult.getByToolName(toolName));
}; };

View File

@ -1,23 +1,9 @@
import { queries, buildQueries } from "@testing-library/react"; import { queries, buildQueries } from "@testing-library/react";
import { ToolType } from "../../types";
import { TOOL_TYPE } from "../../constants";
const toolMap = { const _getAllByToolName = (container: HTMLElement, tool: ToolType | "lock") => {
lock: "lock", const toolTitle = tool === "lock" ? "lock" : TOOL_TYPE[tool];
selection: "selection",
rectangle: "rectangle",
diamond: "diamond",
ellipse: "ellipse",
arrow: "arrow",
line: "line",
freedraw: "freedraw",
text: "text",
eraser: "eraser",
frame: "frame",
};
export type ToolName = keyof typeof toolMap;
const _getAllByToolName = (container: HTMLElement, tool: string) => {
const toolTitle = toolMap[tool as ToolName];
return queries.getAllByTestId(container, `toolbar-${toolTitle}`); return queries.getAllByTestId(container, `toolbar-${toolTitle}`);
}; };
@ -32,7 +18,7 @@ export const [
getByToolName, getByToolName,
findAllByToolName, findAllByToolName,
findByToolName, findByToolName,
] = buildQueries<string[]>( ] = buildQueries<(ToolType | "lock")[]>(
_getAllByToolName, _getAllByToolName,
getMultipleError, getMultipleError,
getMissingError, getMissingError,

View File

@ -15,8 +15,10 @@ import {
ExcalidrawImageElement, ExcalidrawImageElement,
Theme, Theme,
StrokeRoundness, StrokeRoundness,
ExcalidrawFrameElement,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
ExcalidrawMagicFrameElement,
ExcalidrawFrameLikeElement,
ExcalidrawElementType,
} from "./element/types"; } from "./element/types";
import { Action } from "./actions/types"; import { Action } from "./actions/types";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -102,9 +104,12 @@ export type ToolType =
| "eraser" | "eraser"
| "hand" | "hand"
| "frame" | "frame"
| "magicframe"
| "embeddable" | "embeddable"
| "laser"; | "laser";
export type ElementOrToolType = ExcalidrawElementType | ToolType | "custom";
export type ActiveTool = export type ActiveTool =
| { | {
type: ToolType; type: ToolType;
@ -159,9 +164,6 @@ export type InteractiveCanvasAppState = Readonly<
suggestedBindings: AppState["suggestedBindings"]; suggestedBindings: AppState["suggestedBindings"];
isRotating: AppState["isRotating"]; isRotating: AppState["isRotating"];
elementsToHighlight: AppState["elementsToHighlight"]; elementsToHighlight: AppState["elementsToHighlight"];
// App
openSidebar: AppState["openSidebar"];
showHyperlinkPopup: AppState["showHyperlinkPopup"];
// Collaborators // Collaborators
collaborators: AppState["collaborators"]; collaborators: AppState["collaborators"];
// SnapLines // SnapLines
@ -170,7 +172,7 @@ export type InteractiveCanvasAppState = Readonly<
} }
>; >;
export type AppState = { export interface AppState {
contextMenu: { contextMenu: {
items: ContextMenuItems; items: ContextMenuItems;
top: number; top: number;
@ -190,7 +192,7 @@ export type AppState = {
isBindingEnabled: boolean; isBindingEnabled: boolean;
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null; startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
suggestedBindings: SuggestedBinding[]; suggestedBindings: SuggestedBinding[];
frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null; frameToHighlight: NonDeleted<ExcalidrawFrameLikeElement> | null;
frameRendering: { frameRendering: {
enabled: boolean; enabled: boolean;
name: boolean; name: boolean;
@ -242,7 +244,13 @@ export type AppState = {
openMenu: "canvas" | "shape" | null; openMenu: "canvas" | "shape" | null;
openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null; openPopup: "canvasBackground" | "elementBackground" | "elementStroke" | null;
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null; openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
openDialog: "imageExport" | "help" | "jsonExport" | "mermaid" | null; openDialog:
| "imageExport"
| "help"
| "jsonExport"
| "mermaid"
| "magicSettings"
| null;
/** /**
* Reflects user preference for whether the default sidebar should be docked. * Reflects user preference for whether the default sidebar should be docked.
* *
@ -297,7 +305,7 @@ export type AppState = {
y: number; y: number;
} | null; } | null;
objectsSnapModeEnabled: boolean; objectsSnapModeEnabled: boolean;
}; }
export type UIAppState = Omit< export type UIAppState = Omit<
AppState, AppState,
@ -437,6 +445,7 @@ export interface ExcalidrawProps {
element: NonDeleted<ExcalidrawEmbeddableElement>, element: NonDeleted<ExcalidrawEmbeddableElement>,
appState: AppState, appState: AppState,
) => JSX.Element | null; ) => JSX.Element | null;
aiEnabled?: boolean;
} }
export type SceneData = { export type SceneData = {
@ -505,6 +514,7 @@ export type AppProps = Merge<
handleKeyboardGlobally: boolean; handleKeyboardGlobally: boolean;
isCollaborating: boolean; isCollaborating: boolean;
children?: React.ReactNode; children?: React.ReactNode;
aiEnabled: boolean;
} }
>; >;
@ -538,6 +548,8 @@ export type AppClassProperties = {
togglePenMode: App["togglePenMode"]; togglePenMode: App["togglePenMode"];
setActiveTool: App["setActiveTool"]; setActiveTool: App["setActiveTool"];
setOpenDialog: App["setOpenDialog"]; setOpenDialog: App["setOpenDialog"];
insertEmbeddableElement: App["insertEmbeddableElement"];
onMagicButtonSelect: App["onMagicButtonSelect"];
}; };
export type PointerDownState = Readonly<{ export type PointerDownState = Readonly<{
@ -681,12 +693,14 @@ type FrameNameBounds = {
}; };
export type FrameNameBoundsCache = { export type FrameNameBoundsCache = {
get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null; get: (
frameElement: ExcalidrawFrameLikeElement | ExcalidrawMagicFrameElement,
) => FrameNameBounds | null;
_cache: Map< _cache: Map<
string, string,
FrameNameBounds & { FrameNameBounds & {
zoom: AppState["zoom"]["value"]; zoom: AppState["zoom"]["value"];
versionNonce: ExcalidrawFrameElement["versionNonce"]; versionNonce: ExcalidrawFrameLikeElement["versionNonce"];
} }
>; >;
}; };
@ -706,3 +720,5 @@ export type Primitive =
| symbol | symbol
| null | null
| undefined; | undefined;
export type JSONValue = string | number | boolean | null | object;

View File

@ -6,11 +6,7 @@ import {
isDarwin, isDarwin,
WINDOWS_EMOJI_FALLBACK_FONT, WINDOWS_EMOJI_FALLBACK_FONT,
} from "./constants"; } from "./constants";
import { import { FontFamilyValues, FontString } from "./element/types";
FontFamilyValues,
FontString,
NonDeletedExcalidrawElement,
} from "./element/types";
import { ActiveTool, AppState, ToolType, Zoom } from "./types"; import { ActiveTool, AppState, ToolType, Zoom } from "./types";
import { unstable_batchedUpdates } from "react-dom"; import { unstable_batchedUpdates } from "react-dom";
import { ResolutionType } from "./utility-types"; import { ResolutionType } from "./utility-types";
@ -77,7 +73,9 @@ export const isWritableElement = (
target instanceof HTMLBRElement || // newline in wysiwyg target instanceof HTMLBRElement || // newline in wysiwyg
target instanceof HTMLTextAreaElement || target instanceof HTMLTextAreaElement ||
(target instanceof HTMLInputElement && (target instanceof HTMLInputElement &&
(target.type === "text" || target.type === "number")); (target.type === "text" ||
target.type === "number" ||
target.type === "password"));
export const getFontFamilyString = ({ export const getFontFamilyString = ({
fontFamily, fontFamily,
@ -821,19 +819,6 @@ export const composeEventHandlers = <E>(
}; };
}; };
export const isOnlyExportingSingleFrame = (
elements: readonly NonDeletedExcalidrawElement[],
) => {
const frames = elements.filter((element) => element.type === "frame");
return (
frames.length === 1 &&
elements.every(
(element) => element.type === "frame" || element.frameId === frames[0].id,
)
);
};
/** /**
* supply `null` as message if non-never value is valid, you just need to * supply `null` as message if non-never value is valid, you just need to
* typecheck against it * typecheck against it

View File

@ -1,6 +1,6 @@
import { bumpVersion } from "./element/mutateElement"; import { bumpVersion } from "./element/mutateElement";
import { isFrameElement } from "./element/typeChecks"; import { isFrameLikeElement } from "./element/typeChecks";
import { ExcalidrawElement, ExcalidrawFrameElement } from "./element/types"; import { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./element/types";
import { getElementsInGroup } from "./groups"; import { getElementsInGroup } from "./groups";
import { getSelectedElements } from "./scene"; import { getSelectedElements } from "./scene";
import Scene from "./scene/Scene"; import Scene from "./scene/Scene";
@ -107,7 +107,7 @@ const getTargetIndexAccountingForBinding = (
const getContiguousFrameRangeElements = ( const getContiguousFrameRangeElements = (
allElements: readonly ExcalidrawElement[], allElements: readonly ExcalidrawElement[],
frameId: ExcalidrawFrameElement["id"], frameId: ExcalidrawFrameLikeElement["id"],
) => { ) => {
let rangeStart = -1; let rangeStart = -1;
let rangeEnd = -1; let rangeEnd = -1;
@ -138,7 +138,7 @@ const getTargetIndex = (
* Frame id if moving frame children. * Frame id if moving frame children.
* If whole frame (including all children) is being moved, supply `null`. * If whole frame (including all children) is being moved, supply `null`.
*/ */
containingFrame: ExcalidrawFrameElement["id"] | null, containingFrame: ExcalidrawFrameLikeElement["id"] | null,
) => { ) => {
const sourceElement = elements[boundaryIndex]; const sourceElement = elements[boundaryIndex];
@ -189,7 +189,7 @@ const getTargetIndex = (
if ( if (
!containingFrame && !containingFrame &&
(nextElement.frameId || nextElement.type === "frame") (nextElement.frameId || isFrameLikeElement(nextElement))
) { ) {
const frameElements = getContiguousFrameRangeElements( const frameElements = getContiguousFrameRangeElements(
elements, elements,
@ -252,9 +252,9 @@ const shiftElementsByOne = (
groupedIndices = groupedIndices.reverse(); groupedIndices = groupedIndices.reverse();
} }
const selectedFrames = new Set<ExcalidrawFrameElement["id"]>( const selectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>(
indicesToMove indicesToMove
.filter((idx) => elements[idx].type === "frame") .filter((idx) => isFrameLikeElement(elements[idx]))
.map((idx) => elements[idx].id), .map((idx) => elements[idx].id),
); );
@ -324,7 +324,7 @@ const shiftElementsToEnd = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null, containingFrame: ExcalidrawFrameLikeElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => { ) => {
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved); const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
@ -413,7 +413,7 @@ function shiftElementsAccountingForFrames(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
direction: "left" | "right", direction: "left" | "right",
containingFrame: ExcalidrawFrameElement["id"] | null, containingFrame: ExcalidrawFrameLikeElement["id"] | null,
elementsToBeMoved?: readonly ExcalidrawElement[], elementsToBeMoved?: readonly ExcalidrawElement[],
) => ExcalidrawElement[] | readonly ExcalidrawElement[], ) => ExcalidrawElement[] | readonly ExcalidrawElement[],
) { ) {
@ -426,13 +426,13 @@ function shiftElementsAccountingForFrames(
const frameAwareContiguousElementsToMove: { const frameAwareContiguousElementsToMove: {
regularElements: ExcalidrawElement[]; regularElements: ExcalidrawElement[];
frameChildren: Map<ExcalidrawFrameElement["id"], ExcalidrawElement[]>; frameChildren: Map<ExcalidrawFrameLikeElement["id"], ExcalidrawElement[]>;
} = { regularElements: [], frameChildren: new Map() }; } = { regularElements: [], frameChildren: new Map() };
const fullySelectedFrames = new Set<ExcalidrawFrameElement["id"]>(); const fullySelectedFrames = new Set<ExcalidrawFrameLikeElement["id"]>();
for (const element of allElements) { for (const element of allElements) {
if (elementsToMove.has(element.id) && isFrameElement(element)) { if (elementsToMove.has(element.id) && isFrameLikeElement(element)) {
fullySelectedFrames.add(element.id); fullySelectedFrames.add(element.id);
} }
} }
@ -440,7 +440,7 @@ function shiftElementsAccountingForFrames(
for (const element of allElements) { for (const element of allElements) {
if (elementsToMove.has(element.id)) { if (elementsToMove.has(element.id)) {
if ( if (
isFrameElement(element) || isFrameLikeElement(element) ||
(element.frameId && fullySelectedFrames.has(element.frameId)) (element.frameId && fullySelectedFrames.has(element.frameId))
) { ) {
frameAwareContiguousElementsToMove.regularElements.push(element); frameAwareContiguousElementsToMove.regularElements.push(element);