feat: wireframe-to-code (#7334)
This commit is contained in:
parent
d1e4421823
commit
c7ee46e7f8
@ -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))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
|
@ -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))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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(
|
||||||
|
@ -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";
|
||||||
|
@ -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,
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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
@ -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()}
|
||||||
|
38
src/components/MagicButton.tsx
Normal file
38
src/components/MagicButton.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { ToolButtonSize } from "./ToolButton";
|
||||||
|
|
||||||
|
const DEFAULT_SIZE: ToolButtonSize = "small";
|
||||||
|
|
||||||
|
export const ElementCanvasButton = (props: {
|
||||||
|
title?: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
name?: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange?(): void;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={clsx(
|
||||||
|
"ToolIcon ToolIcon__MagicButton",
|
||||||
|
`ToolIcon_size_${DEFAULT_SIZE}`,
|
||||||
|
{
|
||||||
|
"is-mobile": props.isMobile,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
title={`${props.title}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="ToolIcon_type_checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
name={props.name}
|
||||||
|
onChange={props.onChange}
|
||||||
|
checked={props.checked}
|
||||||
|
aria-label={props.title}
|
||||||
|
/>
|
||||||
|
<div className="ToolIcon__icon">{props.icon}</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
9
src/components/MagicSettings.scss
Normal file
9
src/components/MagicSettings.scss
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.MagicSettings-confirm {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.MagicSettings__confirm {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
145
src/components/MagicSettings.tsx
Normal file
145
src/components/MagicSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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: {
|
||||||
|
@ -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]);
|
||||||
|
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
51
src/data/EditorLocalStorage.ts
Normal file
51
src/data/EditorLocalStorage.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { EDITOR_LS_KEYS } from "../constants";
|
||||||
|
import { JSONValue } from "../types";
|
||||||
|
|
||||||
|
export class EditorLocalStorage {
|
||||||
|
static has(key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS]) {
|
||||||
|
try {
|
||||||
|
return !!window.localStorage.getItem(key);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static get<T extends JSONValue>(
|
||||||
|
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const value = window.localStorage.getItem(key);
|
||||||
|
if (value) {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`localStorage.getItem error: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static set = (
|
||||||
|
key: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||||
|
value: JSONValue,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`localStorage.setItem error: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
static delete = (
|
||||||
|
name: typeof EDITOR_LS_KEYS[keyof typeof EDITOR_LS_KEYS],
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(name);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`localStorage.removeItem error: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
300
src/data/ai/types.ts
Normal file
300
src/data/ai/types.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
export namespace OpenAIInput {
|
||||||
|
type ChatCompletionContentPart =
|
||||||
|
| ChatCompletionContentPartText
|
||||||
|
| ChatCompletionContentPartImage;
|
||||||
|
|
||||||
|
interface ChatCompletionContentPartImage {
|
||||||
|
image_url: ChatCompletionContentPartImage.ImageURL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the content part.
|
||||||
|
*/
|
||||||
|
type: "image_url";
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace ChatCompletionContentPartImage {
|
||||||
|
export interface ImageURL {
|
||||||
|
/**
|
||||||
|
* Either a URL of the image or the base64 encoded image data.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies the detail level of the image.
|
||||||
|
*/
|
||||||
|
detail?: "auto" | "low" | "high";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionContentPartText {
|
||||||
|
/**
|
||||||
|
* The text content.
|
||||||
|
*/
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of the content part.
|
||||||
|
*/
|
||||||
|
type: "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionUserMessageParam {
|
||||||
|
/**
|
||||||
|
* The contents of the user message.
|
||||||
|
*/
|
||||||
|
content: string | Array<ChatCompletionContentPart> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the messages author, in this case `user`.
|
||||||
|
*/
|
||||||
|
role: "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionSystemMessageParam {
|
||||||
|
/**
|
||||||
|
* The contents of the system message.
|
||||||
|
*/
|
||||||
|
content: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the messages author, in this case `system`.
|
||||||
|
*/
|
||||||
|
role: "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionCreateParamsBase {
|
||||||
|
/**
|
||||||
|
* A list of messages comprising the conversation so far.
|
||||||
|
* [Example Python code](https://cookbook.openai.com/examples/how_to_format_inputs_to_chatgpt_models).
|
||||||
|
*/
|
||||||
|
messages: Array<
|
||||||
|
ChatCompletionUserMessageParam | ChatCompletionSystemMessageParam
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID of the model to use. See the
|
||||||
|
* [model endpoint compatibility](https://platform.openai.com/docs/models/model-endpoint-compatibility)
|
||||||
|
* table for details on which models work with the Chat API.
|
||||||
|
*/
|
||||||
|
model:
|
||||||
|
| (string & {})
|
||||||
|
| "gpt-4-1106-preview"
|
||||||
|
| "gpt-4-vision-preview"
|
||||||
|
| "gpt-4"
|
||||||
|
| "gpt-4-0314"
|
||||||
|
| "gpt-4-0613"
|
||||||
|
| "gpt-4-32k"
|
||||||
|
| "gpt-4-32k-0314"
|
||||||
|
| "gpt-4-32k-0613"
|
||||||
|
| "gpt-3.5-turbo"
|
||||||
|
| "gpt-3.5-turbo-16k"
|
||||||
|
| "gpt-3.5-turbo-0301"
|
||||||
|
| "gpt-3.5-turbo-0613"
|
||||||
|
| "gpt-3.5-turbo-16k-0613";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number between -2.0 and 2.0. Positive values penalize new tokens based on their
|
||||||
|
* existing frequency in the text so far, decreasing the model's likelihood to
|
||||||
|
* repeat the same line verbatim.
|
||||||
|
*
|
||||||
|
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||||
|
*/
|
||||||
|
frequency_penalty?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modify the likelihood of specified tokens appearing in the completion.
|
||||||
|
*
|
||||||
|
* Accepts a JSON object that maps tokens (specified by their token ID in the
|
||||||
|
* tokenizer) to an associated bias value from -100 to 100. Mathematically, the
|
||||||
|
* bias is added to the logits generated by the model prior to sampling. The exact
|
||||||
|
* effect will vary per model, but values between -1 and 1 should decrease or
|
||||||
|
* increase likelihood of selection; values like -100 or 100 should result in a ban
|
||||||
|
* or exclusive selection of the relevant token.
|
||||||
|
*/
|
||||||
|
logit_bias?: Record<string, number> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum number of [tokens](/tokenizer) to generate in the chat completion.
|
||||||
|
*
|
||||||
|
* The total length of input tokens and generated tokens is limited by the model's
|
||||||
|
* context length.
|
||||||
|
* [Example Python code](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken)
|
||||||
|
* for counting tokens.
|
||||||
|
*/
|
||||||
|
max_tokens?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many chat completion choices to generate for each input message.
|
||||||
|
*/
|
||||||
|
n?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number between -2.0 and 2.0. Positive values penalize new tokens based on
|
||||||
|
* whether they appear in the text so far, increasing the model's likelihood to
|
||||||
|
* talk about new topics.
|
||||||
|
*
|
||||||
|
* [See more information about frequency and presence penalties.](https://platform.openai.com/docs/guides/gpt/parameter-details)
|
||||||
|
*/
|
||||||
|
presence_penalty?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This feature is in Beta. If specified, our system will make a best effort to
|
||||||
|
* sample deterministically, such that repeated requests with the same `seed` and
|
||||||
|
* parameters should return the same result. Determinism is not guaranteed, and you
|
||||||
|
* should refer to the `system_fingerprint` response parameter to monitor changes
|
||||||
|
* in the backend.
|
||||||
|
*/
|
||||||
|
seed?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Up to 4 sequences where the API will stop generating further tokens.
|
||||||
|
*/
|
||||||
|
stop?: string | null | Array<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set, partial message deltas will be sent, like in ChatGPT. Tokens will be
|
||||||
|
* sent as data-only
|
||||||
|
* [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format)
|
||||||
|
* as they become available, with the stream terminated by a `data: [DONE]`
|
||||||
|
* message.
|
||||||
|
* [Example Python code](https://cookbook.openai.com/examples/how_to_stream_completions).
|
||||||
|
*/
|
||||||
|
stream?: boolean | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What sampling temperature to use, between 0 and 2. Higher values like 0.8 will
|
||||||
|
* make the output more random, while lower values like 0.2 will make it more
|
||||||
|
* focused and deterministic.
|
||||||
|
*
|
||||||
|
* We generally recommend altering this or `top_p` but not both.
|
||||||
|
*/
|
||||||
|
temperature?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An alternative to sampling with temperature, called nucleus sampling, where the
|
||||||
|
* model considers the results of the tokens with top_p probability mass. So 0.1
|
||||||
|
* means only the tokens comprising the top 10% probability mass are considered.
|
||||||
|
*
|
||||||
|
* We generally recommend altering this or `temperature` but not both.
|
||||||
|
*/
|
||||||
|
top_p?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A unique identifier representing your end-user, which can help OpenAI to monitor
|
||||||
|
* and detect abuse.
|
||||||
|
* [Learn more](https://platform.openai.com/docs/guides/safety-best-practices/end-user-ids).
|
||||||
|
*/
|
||||||
|
user?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace OpenAIOutput {
|
||||||
|
export interface ChatCompletion {
|
||||||
|
/**
|
||||||
|
* A unique identifier for the chat completion.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of chat completion choices. Can be more than one if `n` is greater
|
||||||
|
* than 1.
|
||||||
|
*/
|
||||||
|
choices: Array<Choice>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Unix timestamp (in seconds) of when the chat completion was created.
|
||||||
|
*/
|
||||||
|
created: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model used for the chat completion.
|
||||||
|
*/
|
||||||
|
model: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The object type, which is always `chat.completion`.
|
||||||
|
*/
|
||||||
|
object: "chat.completion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This fingerprint represents the backend configuration that the model runs with.
|
||||||
|
*
|
||||||
|
* Can be used in conjunction with the `seed` request parameter to understand when
|
||||||
|
* backend changes have been made that might impact determinism.
|
||||||
|
*/
|
||||||
|
system_fingerprint?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage statistics for the completion request.
|
||||||
|
*/
|
||||||
|
usage?: CompletionUsage;
|
||||||
|
}
|
||||||
|
export interface Choice {
|
||||||
|
/**
|
||||||
|
* The reason the model stopped generating tokens. This will be `stop` if the model
|
||||||
|
* hit a natural stop point or a provided stop sequence, `length` if the maximum
|
||||||
|
* number of tokens specified in the request was reached, `content_filter` if
|
||||||
|
* content was omitted due to a flag from our content filters, `tool_calls` if the
|
||||||
|
* model called a tool, or `function_call` (deprecated) if the model called a
|
||||||
|
* function.
|
||||||
|
*/
|
||||||
|
finish_reason:
|
||||||
|
| "stop"
|
||||||
|
| "length"
|
||||||
|
| "tool_calls"
|
||||||
|
| "content_filter"
|
||||||
|
| "function_call";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The index of the choice in the list of choices.
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chat completion message generated by the model.
|
||||||
|
*/
|
||||||
|
message: ChatCompletionMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionMessage {
|
||||||
|
/**
|
||||||
|
* The contents of the message.
|
||||||
|
*/
|
||||||
|
content: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The role of the author of this message.
|
||||||
|
*/
|
||||||
|
role: "assistant";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage statistics for the completion request.
|
||||||
|
*/
|
||||||
|
interface CompletionUsage {
|
||||||
|
/**
|
||||||
|
* Number of tokens in the generated completion.
|
||||||
|
*/
|
||||||
|
completion_tokens: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of tokens in the prompt.
|
||||||
|
*/
|
||||||
|
prompt_tokens: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total number of tokens used in the request (prompt + completion).
|
||||||
|
*/
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIError {
|
||||||
|
readonly status: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500 | undefined;
|
||||||
|
readonly headers: Headers | undefined;
|
||||||
|
readonly error: { message: string } | undefined;
|
||||||
|
|
||||||
|
readonly code: string | null | undefined;
|
||||||
|
readonly param: string | null | undefined;
|
||||||
|
readonly type: string | undefined;
|
||||||
|
}
|
||||||
|
}
|
@ -3,10 +3,11 @@ import {
|
|||||||
copyTextToSystemClipboard,
|
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
104
src/data/magic.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { Theme } from "../element/types";
|
||||||
|
import { DataURL } from "../types";
|
||||||
|
import { OpenAIInput, OpenAIOutput } from "./ai/types";
|
||||||
|
|
||||||
|
export type MagicCacheData =
|
||||||
|
| {
|
||||||
|
status: "pending";
|
||||||
|
}
|
||||||
|
| { status: "done"; html: string }
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
message?: string;
|
||||||
|
code: "ERR_GENERATION_INTERRUPTED" | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||||
|
Your role is to transform low-fidelity wireframes into working front-end HTML code.
|
||||||
|
|
||||||
|
YOU MUST FOLLOW FOLLOWING RULES:
|
||||||
|
|
||||||
|
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype
|
||||||
|
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>)
|
||||||
|
- Inline JavaScript when needed
|
||||||
|
- Fetch dependencies from CDNs when needed (using unpkg or skypack)
|
||||||
|
- Source images from Unsplash or create applicable placeholders
|
||||||
|
- Interpret annotations as intended vs literal UI
|
||||||
|
- Fill gaps using your expertise in UX and business logic
|
||||||
|
- generate primarily for desktop UI, but make it responsive.
|
||||||
|
- Use grid and flexbox wherever applicable.
|
||||||
|
- Convert the wireframe in its entirety, don't omit elements if possible.
|
||||||
|
|
||||||
|
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification.
|
||||||
|
|
||||||
|
Your goal is a production-ready prototype that brings the wireframes to life.
|
||||||
|
|
||||||
|
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||||
|
|
||||||
|
export async function diagramToHTML({
|
||||||
|
image,
|
||||||
|
apiKey,
|
||||||
|
text,
|
||||||
|
theme = "light",
|
||||||
|
}: {
|
||||||
|
image: DataURL;
|
||||||
|
apiKey: string;
|
||||||
|
text: string;
|
||||||
|
theme?: Theme;
|
||||||
|
}) {
|
||||||
|
const body: OpenAIInput.ChatCompletionCreateParamsBase = {
|
||||||
|
model: "gpt-4-vision-preview",
|
||||||
|
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||||
|
max_tokens: 4096,
|
||||||
|
temperature: 0.1,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: SYSTEM_PROMPT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: image,
|
||||||
|
detail: "high",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let result:
|
||||||
|
| ({ ok: true } & OpenAIOutput.ChatCompletion)
|
||||||
|
| ({ ok: false } & OpenAIOutput.APIError);
|
||||||
|
|
||||||
|
const resp = await fetch("https://api.openai.com/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const json: OpenAIOutput.ChatCompletion = await resp.json();
|
||||||
|
result = { ...json, ok: true };
|
||||||
|
} else {
|
||||||
|
const json: OpenAIOutput.APIError = await resp.json();
|
||||||
|
result = { ...json, ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
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,
|
||||||
|
@ -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);
|
||||||
|
14
src/element/ElementCanvasButtons.scss
Normal file
14
src/element/ElementCanvasButtons.scss
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
.excalidraw {
|
||||||
|
.excalidraw-canvas-buttons {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
|
||||||
|
z-index: var(--zIndex-canvasButtons);
|
||||||
|
background: var(--island-bg-color);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
}
|
60
src/element/ElementCanvasButtons.tsx
Normal file
60
src/element/ElementCanvasButtons.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { AppState } from "../types";
|
||||||
|
import { sceneCoordsToViewportCoords } from "../utils";
|
||||||
|
import { NonDeletedExcalidrawElement } from "./types";
|
||||||
|
import { getElementAbsoluteCoords } from ".";
|
||||||
|
import { useExcalidrawAppState } from "../components/App";
|
||||||
|
|
||||||
|
import "./ElementCanvasButtons.scss";
|
||||||
|
|
||||||
|
const CONTAINER_PADDING = 5;
|
||||||
|
|
||||||
|
const getContainerCoords = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const [x1, y1] = getElementAbsoluteCoords(element);
|
||||||
|
const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
|
||||||
|
{ sceneX: x1 + element.width, sceneY: y1 },
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
const x = viewportX - appState.offsetLeft + 10;
|
||||||
|
const y = viewportY - appState.offsetTop;
|
||||||
|
return { x, y };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ElementCanvasButtons = ({
|
||||||
|
children,
|
||||||
|
element,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
element: NonDeletedExcalidrawElement;
|
||||||
|
}) => {
|
||||||
|
const appState = useExcalidrawAppState();
|
||||||
|
|
||||||
|
if (
|
||||||
|
appState.contextMenu ||
|
||||||
|
appState.draggingElement ||
|
||||||
|
appState.resizingElement ||
|
||||||
|
appState.isRotating ||
|
||||||
|
appState.openMenu ||
|
||||||
|
appState.viewModeEnabled
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x, y } = getContainerCoords(element, appState);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="excalidraw-canvas-buttons"
|
||||||
|
style={{
|
||||||
|
top: `${y}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
// width: CONTAINER_WIDTH,
|
||||||
|
padding: CONTAINER_PADDING,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -6,7 +6,7 @@
|
|||||||
justify-content: space-between;
|
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;
|
||||||
|
@ -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 ||
|
||||||
|
@ -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,
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
|
@ -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 = (
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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";
|
||||||
|
@ -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"];
|
||||||
|
84
src/frame.ts
84
src/frame.ts
@ -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}`;
|
||||||
|
};
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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) {
|
||||||
|
@ -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),
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
@ -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));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
36
src/types.ts
36
src/types.ts
@ -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;
|
||||||
|
23
src/utils.ts
23
src/utils.ts
@ -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
|
||||||
|
@ -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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user