diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index cd0aca2683..a3f847385f 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -62,7 +62,7 @@ export const AppMainMenu: React.FC<{ {isDevEnv() && ( { + onSelect={() => { if (window.visualDebug) { delete window.visualDebug; saveDebugState({ enabled: false }); @@ -77,6 +77,7 @@ export const AppMainMenu: React.FC<{ )} + + {t("welcomeScreen.app.center_heading")} +
+ {t("welcomeScreen.app.center_heading_line2")} +
+ {t("welcomeScreen.app.center_heading_line3")} + + ); } return ( diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap index 53b16c40ff..8bdc0f99ff 100644 --- a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -50,7 +50,11 @@ exports[`Test MobileMenu > should initialize with welcome screen and hide once u
- All your data is saved locally in your browser. + Your drawings are saved in your browser's storage. +
+ Browser storage can be cleared unexpectedly. +
+ Save your work to a file regularly to avoid losing it.
parseFloat(num.toPrecision(precision)); @@ -157,6 +151,70 @@ export class Debug { return ret; }; }; + + private static CHANGED_CACHE: Record> = {}; + + public static logChanged(name: string, obj: Record) { + const prev = Debug.CHANGED_CACHE[name]; + + Debug.CHANGED_CACHE[name] = obj; + + if (!prev) { + return; + } + + const allKeys = new Set([...Object.keys(prev), ...Object.keys(obj)]); + const changed: Record = {}; + + for (const key of allKeys) { + const prevVal = prev[key]; + const nextVal = obj[key]; + if (!deepEqual(prevVal, nextVal)) { + changed[key] = { prev: prevVal, next: nextVal }; + } + } + + if (Object.keys(changed).length > 0) { + console.info(`[${name}] changed:`, changed); + } + } +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) { + return true; + } + + if ( + a === null || + b === null || + typeof a !== "object" || + typeof b !== "object" + ) { + return false; + } + + if (Array.isArray(a) !== Array.isArray(b)) { + return false; + } + + const keysA = Object.keys(a as Record); + const keysB = Object.keys(b as Record); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if ( + !deepEqual( + (a as Record)[key], + (b as Record)[key], + ) + ) { + return false; + } + } + + return true; } -//@ts-ignore -window.debug = Debug; diff --git a/packages/common/src/colors.ts b/packages/common/src/colors.ts index ac2ece156b..39771b754c 100644 --- a/packages/common/src/colors.ts +++ b/packages/common/src/colors.ts @@ -349,7 +349,7 @@ export const normalizeInputColor = (color: string): string | null => { if (tc.isValid()) { // testing for `#` first fixes a bug on Electron (more specfically, an // Obsidian popout window), where a hex color without `#` is considered valid - if (tc.getFormat() === "hex" && !color.startsWith("#")) { + if (["hex", "hex8"].includes(tc.getFormat()) && !color.startsWith("#")) { return `#${color}`; } return color; diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 23d5f426e6..4ff50335ef 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -106,6 +106,7 @@ export const CLASSES = { CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup", SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope", FRAME_NAME: "frame-name", + DROPDOWN_MENU_EVENT_WRAPPER: "dropdown-menu-event-wrapper", }; export const FONT_SIZES = { @@ -251,6 +252,7 @@ export const STRING_MIME_TYPES = { json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", + excalidrawClipboard: "application/vnd.excalidraw.clipboard+json", // LEGACY: fully-qualified library JSON data excalidrawlib: "application/vnd.excalidrawlib+json", // list of excalidraw library item ids diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7d6bf5b0dc..ca5397ddd1 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,3 +12,4 @@ export * from "./url"; export * from "./utils"; export * from "./emitter"; export * from "./editorInterface"; +export { Debug } from "../debug"; diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index b70f05c794..403598b455 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,5 +1,7 @@ import { average } from "@excalidraw/math"; +import type { GlobalCoord } from "@excalidraw/math"; + import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { @@ -441,7 +443,7 @@ export const viewportCoordsToSceneCoords = ( const x = (clientX - offsetLeft) / zoom.value - scrollX; const y = (clientY - offsetTop) / zoom.value - scrollY; - return { x, y }; + return { x, y } as GlobalCoord; }; export const sceneCoordsToViewportCoords = ( @@ -1339,3 +1341,10 @@ export const setFeatureFlag = ( console.error("unable to set feature flag", e); } }; + +export const oneOf = ( + needle: N, + haystack: readonly H[], +): needle is H => { + return haystack.includes(needle as any); +}; diff --git a/packages/element/src/arrows/focus.ts b/packages/element/src/arrows/focus.ts new file mode 100644 index 0000000000..fa8018adbe --- /dev/null +++ b/packages/element/src/arrows/focus.ts @@ -0,0 +1,558 @@ +import { pointDistance, pointFrom, type GlobalPoint } from "@excalidraw/math"; +import { invariant } from "@excalidraw/common"; + +import type { AppState, NullableGridSize } from "@excalidraw/excalidraw/types"; + +import { + bindBindingElement, + calculateFixedPointForNonElbowArrowBinding, + FOCUS_POINT_SIZE, + getBindingGap, + getGlobalFixedPointForBindableElement, + isBindingEnabled, + maxBindingDistance_simple, + unbindBindingElement, + updateBoundPoint, +} from "../binding"; +import { + isBindableElement, + isBindingElement, + isElbowArrow, +} from "../typeChecks"; +import { LinearElementEditor } from "../linearElementEditor"; +import { getHoveredElementForFocusPoint, hitElementItself } from "../collision"; +import { moveArrowAboveBindable } from "../zindex"; + +import type { + ElementsMap, + ExcalidrawArrowElement, + ExcalidrawBindableElement, + NonDeletedSceneElementsMap, + PointsPositionUpdates, +} from "../types"; + +import type { Scene } from "../Scene"; + +export const isFocusPointVisible = ( + focusPoint: GlobalPoint, + arrow: ExcalidrawArrowElement, + bindableElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, + appState: { + isBindingEnabled: AppState["isBindingEnabled"]; + zoom: AppState["zoom"]; + }, + startOrEnd: "start" | "end", + ignoreOverlap = false, +): boolean => { + // No focus point management for elbow arrows, because elbow arrows + // always have their focus point at the arrow point itself + if ( + isElbowArrow(arrow) || + !isBindingEnabled(appState) || + arrow.points.length !== 2 + ) { + return false; + } + + // Avoid showing the focus point indicator if the focus point is essentially + // on top of the arrow point it belongs to itself, if not ignoring specifically + if (!ignoreOverlap) { + const associatedPointIdx = + arrow.startBinding?.elementId === bindableElement.id + ? 0 + : arrow.points.length - 1; + const associatedArrowPoint = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + associatedPointIdx, + elementsMap, + ); + + if ( + pointDistance(focusPoint, associatedArrowPoint) < + (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value + ) { + return false; + } + } + + const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "end" ? arrow.points.length - 1 : 0, + elementsMap, + ); + + // Check if the focus point is within the element's shape bounds + // Endpoint dragging takes precedence + return ( + pointDistance(focusPoint, arrowPoint) >= + (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value && + hitElementItself({ + element: bindableElement, + elementsMap, + point: focusPoint, + threshold: getBindingGap(bindableElement, arrow), + overrideShouldTestInside: true, + }) + ); +}; + +// Updates the arrow endpoints in "orbit" configuration +const focusPointUpdate = ( + arrow: ExcalidrawArrowElement, + bindableElement: ExcalidrawBindableElement | null, + isStartBinding: boolean, + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, + appState: AppState, + switchToInsideBinding: boolean, +) => { + const pointUpdates = new Map(); + + const bindingField = isStartBinding ? "startBinding" : "endBinding"; + const adjacentBindingField = isStartBinding ? "endBinding" : "startBinding"; + let currentBinding = arrow[bindingField]; + let adjacentBinding = arrow[adjacentBindingField]; + + // Update the dragged focus point related end + if (currentBinding && bindableElement) { + // Update the targeted bindings + const boundToSameElement = + bindableElement && + adjacentBinding && + currentBinding.elementId === adjacentBinding.elementId; + if (switchToInsideBinding || boundToSameElement) { + currentBinding = { + ...currentBinding, + mode: "inside", + }; + } else { + currentBinding = { + ...currentBinding, + mode: "orbit", + }; + } + + const pointIndex = isStartBinding ? 0 : arrow.points.length - 1; + const newPoint = updateBoundPoint( + arrow, + bindingField as "startBinding" | "endBinding", + currentBinding, + bindableElement, + elementsMap, + true, + ); + + if (newPoint) { + pointUpdates.set(pointIndex, { point: newPoint }); + } + } + + // Also update the adjacent end if it has a binding + if (adjacentBinding && adjacentBinding.mode === "orbit") { + const adjacentBindableElement = elementsMap.get( + adjacentBinding.elementId, + ) as ExcalidrawBindableElement; + + if ( + adjacentBindableElement && + isBindableElement(adjacentBindableElement) && + isBindingEnabled(appState) + ) { + // Same shape bound on both ends + const boundToSameElementAfterUpdate = + bindableElement && adjacentBinding.elementId === bindableElement.id; + if (switchToInsideBinding || boundToSameElementAfterUpdate) { + adjacentBinding = { + ...adjacentBinding, + mode: "inside", + }; + } else { + adjacentBinding = { + ...adjacentBinding, + mode: "orbit", + }; + } + + const adjacentPointIndex = isStartBinding ? arrow.points.length - 1 : 0; + const adjacentNewPoint = updateBoundPoint( + arrow, + adjacentBindingField, + adjacentBinding, + adjacentBindableElement, + elementsMap, + ); + + if (adjacentNewPoint) { + pointUpdates.set(adjacentPointIndex, { + point: adjacentNewPoint, + }); + } + } + } + + if (pointUpdates.size > 0) { + LinearElementEditor.movePoints(arrow, scene, pointUpdates, { + [bindingField]: currentBinding, + [adjacentBindingField]: adjacentBinding, + }); + } +}; + +export const handleFocusPointDrag = ( + linearElementEditor: LinearElementEditor, + elementsMap: NonDeletedSceneElementsMap, + pointerCoords: { x: number; y: number }, + scene: Scene, + appState: AppState, + gridSize: NullableGridSize, + switchToInsideBinding: boolean, +) => { + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ) as any; + + // Sanity checks + if ( + !arrow || + !isBindingElement(arrow) || + isElbowArrow(arrow) || + !linearElementEditor.hoveredFocusPointBinding || + !linearElementEditor.draggedFocusPointBinding + ) { + return; + } + + const isStartBinding = + linearElementEditor.draggedFocusPointBinding === "start"; + const binding = isStartBinding ? arrow.startBinding : arrow.endBinding; + const { x: offsetX, y: offsetY } = linearElementEditor.pointerOffset; + const point = pointFrom( + pointerCoords.x - offsetX, + pointerCoords.y - offsetY, + ); + const bindingField = isStartBinding ? "startBinding" : "endBinding"; + const hit = getHoveredElementForFocusPoint( + point, + arrow, + scene.getNonDeletedElements(), + elementsMap, + maxBindingDistance_simple(appState.zoom), + ); + + // Hovering a bindable element + if (hit && isBindingEnabled(appState)) { + // Break existing binding if bound to another shape or if binding is disabled + if (arrow[bindingField] && hit.id !== binding?.elementId) { + unbindBindingElement( + arrow, + linearElementEditor.draggedFocusPointBinding, + scene, + ); + } + + // Handle binding mode switch + const newMode = + switchToInsideBinding && arrow[bindingField]?.mode === "orbit" + ? "inside" + : !switchToInsideBinding && arrow[bindingField]?.mode === "inside" + ? "orbit" + : null; + + // If no existing binding, create it + if (!arrow[bindingField] || newMode) { + // Create a new binding if none exists + bindBindingElement( + arrow, + hit, + newMode || "orbit", + linearElementEditor.draggedFocusPointBinding, + scene, + point, + ); + } + + // Update the binding's fixed point + scene.mutateElement(arrow, { + [bindingField]: { + ...arrow[bindingField], + elementId: hit.id, + mode: newMode || arrow[bindingField]?.mode || "orbit", + ...calculateFixedPointForNonElbowArrowBinding( + arrow, + hit, + linearElementEditor.draggedFocusPointBinding, + elementsMap, + point, + ), + }, + }); + } else { + // Not hovering any bindable element, move the arrow endpoint + const pointUpdates: PointsPositionUpdates = new Map(); + const pointIndex = isStartBinding ? 0 : arrow.points.length - 1; + pointUpdates.set(pointIndex, { + point: LinearElementEditor.createPointAt( + arrow, + elementsMap, + point[0], + point[1], + gridSize, + ), + }); + LinearElementEditor.movePoints(arrow, scene, pointUpdates); + if (arrow[bindingField]) { + unbindBindingElement(arrow, isStartBinding ? "start" : "end", scene); + } + } + + // Update the arrow endpoints + focusPointUpdate( + arrow, + hit, + isStartBinding, + elementsMap, + scene, + appState, + switchToInsideBinding, + ); + + if (hit && isBindingEnabled(appState)) { + moveArrowAboveBindable( + point, + arrow, + scene.getElementsIncludingDeleted(), + elementsMap, + scene, + hit, + ); + } +}; + +export const handleFocusPointPointerDown = ( + arrow: ExcalidrawArrowElement, + pointerDownState: { origin: { x: number; y: number } }, + elementsMap: NonDeletedSceneElementsMap, + appState: AppState, +): { + hitFocusPoint: "start" | "end" | null; + pointerOffset: { x: number; y: number }; +} => { + const pointerPos = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ); + const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value; + + // Check start binding focus point + if (arrow.startBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.startBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + "start", + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return { + hitFocusPoint: "start", + pointerOffset: { + x: pointerPos[0] - focusPoint[0], + y: pointerPos[1] - focusPoint[1], + }, + }; + } + } + } + + // Check end binding focus point (only if start not already hit) + if (arrow.endBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.endBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + "end", + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return { + hitFocusPoint: "end", + pointerOffset: { + x: pointerPos[0] - focusPoint[0], + y: pointerPos[1] - focusPoint[1], + }, + }; + } + } + } + + return { + hitFocusPoint: null, + pointerOffset: { x: 0, y: 0 }, + }; +}; + +export const handleFocusPointPointerUp = ( + linearElementEditor: LinearElementEditor, + scene: Scene, +) => { + invariant( + linearElementEditor.draggedFocusPointBinding, + "Must have a dragged focus point at pointer release", + ); + + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + scene.getNonDeletedElementsMap(), + ); + invariant(arrow, "Arrow must be in the scene"); + + // Clean up + const bindingKey = + linearElementEditor.draggedFocusPointBinding === "start" + ? "startBinding" + : "endBinding"; + const otherBindingKey = + linearElementEditor.draggedFocusPointBinding === "start" + ? "endBinding" + : "startBinding"; + const boundElementId = arrow[bindingKey]?.elementId; + const otherBoundElementId = arrow[otherBindingKey]?.elementId; + const oldBoundElement = + boundElementId && + scene + .getNonDeletedElements() + .find( + (element) => + element.id !== boundElementId && + element.id !== otherBoundElementId && + isBindableElement(element) && + element.boundElements?.find(({ id }) => id === arrow.id), + ); + if (oldBoundElement) { + scene.mutateElement(oldBoundElement, { + boundElements: oldBoundElement.boundElements?.filter( + ({ id }) => id !== arrow.id, + ), + }); + } + + // Record the new bound element + const boundElement = + boundElementId && scene.getNonDeletedElementsMap().get(boundElementId); + if (boundElement) { + scene.mutateElement(boundElement, { + boundElements: [ + ...(boundElement.boundElements || [])?.filter( + ({ id }) => id !== arrow.id, + ), + { + id: arrow.id, + type: "arrow", + }, + ], + }); + } +}; + +export const handleFocusPointHover = ( + arrow: ExcalidrawArrowElement, + scenePointerX: number, + scenePointerY: number, + scene: Scene, + appState: AppState, +): "start" | "end" | null => { + const elementsMap = scene.getNonDeletedElementsMap(); + const pointerPos = pointFrom(scenePointerX, scenePointerY); + const hitThreshold = (FOCUS_POINT_SIZE * 1.5) / appState.zoom.value; + + // Check start binding focus point + if (arrow.startBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.startBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.startBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + "start", + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return "start"; + } + } + } + + // Check end binding focus point (only if start not already hovered) + if (arrow.endBinding?.elementId) { + const bindableElement = elementsMap.get(arrow.endBinding.elementId); + if ( + bindableElement && + isBindableElement(bindableElement) && + !bindableElement.isDeleted + ) { + const focusPoint = getGlobalFixedPointForBindableElement( + arrow.endBinding.fixedPoint, + bindableElement, + elementsMap, + ); + if ( + isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + "end", + ) && + pointDistance(pointerPos, focusPoint) <= hitThreshold + ) { + return "end"; + } + } + } + + return null; +}; diff --git a/packages/element/src/arrows/helpers.ts b/packages/element/src/arrows/helpers.ts new file mode 100644 index 0000000000..9f7c4ae9b3 --- /dev/null +++ b/packages/element/src/arrows/helpers.ts @@ -0,0 +1,45 @@ +import type { App } from "@excalidraw/excalidraw/types"; + +import { LinearElementEditor } from "../linearElementEditor"; + +import { handleFocusPointDrag } from "./focus"; + +export const maybeHandleArrowPointlikeDrag = ({ + app, + event, +}: { + app: App; + event: KeyboardEvent | React.KeyboardEvent | PointerEvent; +}): boolean => { + const appState = app.state; + if (appState.selectedLinearElement && app.lastPointerMoveCoords) { + // Update focus point status if the binding mode is changing + if (appState.selectedLinearElement.draggedFocusPointBinding) { + handleFocusPointDrag( + appState.selectedLinearElement, + app.scene.getNonDeletedElementsMap(), + app.lastPointerMoveCoords, + app.scene, + appState, + app.getEffectiveGridSize(), + event.altKey, + ); + return true; + } else if ( + appState.selectedLinearElement.hoverPointIndex !== null && + app.lastPointerMoveEvent && + appState.selectedLinearElement.initialState.lastClickedPoint >= 0 && + appState.selectedLinearElement.isDragging + ) { + LinearElementEditor.handlePointDragging( + app.lastPointerMoveEvent, + app, + app.lastPointerMoveCoords.x, + app.lastPointerMoveCoords.y, + appState.selectedLinearElement, + ); + return true; + } + } + return false; +}; diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index ede739dd57..9c22eeb255 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -27,14 +27,11 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { Bounds } from "@excalidraw/common"; -import { - doBoundsIntersect, - getCenterForBounds, - getElementBounds, -} from "./bounds"; +import { getCenterForBounds } from "./bounds"; import { getAllHoveredElementAtPoint, getHoveredElementForBinding, + hitElementItself, intersectElementWithLineSegment, isBindableElementInsideOtherBindable, isPointInElement, @@ -113,8 +110,10 @@ export type BindingStrategy = * * IMPORTANT: currently must be > 0 (this also applies to the computed gap) */ -export const BASE_BINDING_GAP = 10; +export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP_ELBOW = 5; +export const BASE_ARROW_MIN_LENGTH = 10; +export const FOCUS_POINT_SIZE = 10 / 1.5; export const getBindingGap = ( bindTarget: ExcalidrawBindableElement, @@ -144,7 +143,9 @@ export const shouldEnableBindingForPointerEvent = ( return !event[KEYS.CTRL_OR_CMD]; }; -export const isBindingEnabled = (appState: AppState): boolean => { +export const isBindingEnabled = (appState: { + isBindingEnabled: AppState["isBindingEnabled"]; +}): boolean => { return appState.isBindingEnabled; }; @@ -258,7 +259,7 @@ const bindingStrategyForElbowArrowEndpointDragging = ( globalPoint, elements, elementsMap, - () => maxBindingDistance_simple(zoom), + maxBindingDistance_simple(zoom), ); const current = hit @@ -677,7 +678,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( globalPoint, elements, elementsMap, - () => maxBindingDistance_simple(appState.zoom), + maxBindingDistance_simple(appState.zoom), ); const pointInElement = hit && @@ -704,7 +705,13 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( const otherFocusPointIsInElement = otherBindableElement && otherFocusPoint && - isPointInElement(otherFocusPoint, otherBindableElement, elementsMap); + hitElementItself({ + point: otherFocusPoint, + element: otherBindableElement, + elementsMap, + threshold: 0, + overrideShouldTestInside: true, + }); // Handle outside-outside binding to the same element if (otherBinding && otherBinding.elementId === hit?.id) { @@ -784,6 +791,7 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( hit, startDragged ? "start" : "end", elementsMap, + appState.zoom, ) || globalPoint, } : { mode: null }; @@ -793,30 +801,45 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = ( startDragged ? -1 : 0, elementsMap, ); - - const other: BindingStrategy = + const pointIsCloseToOtherElement = + otherFocusPoint && otherBindableElement && - !otherFocusPointIsInElement && - appState.selectedLinearElement?.initialState.altFocusPoint + hitElementItself({ + point: globalPoint, + element: otherBindableElement, + elementsMap, + threshold: maxBindingDistance_simple(appState.zoom), + overrideShouldTestInside: true, + }); + const otherNeverOverride = opts?.newArrow + ? appState.selectedLinearElement?.initialState.arrowStartIsInside + : otherBinding?.mode === "inside"; + const other: BindingStrategy = !otherNeverOverride + ? otherBindableElement && + !otherFocusPointIsInElement && + !pointIsCloseToOtherElement && + appState.selectedLinearElement?.initialState.altFocusPoint ? { mode: "orbit", element: otherBindableElement, focusPoint: appState.selectedLinearElement.initialState.altFocusPoint, } : opts?.angleLocked && otherBindableElement - ? { - mode: "orbit", - element: otherBindableElement, - focusPoint: - projectFixedPointOntoDiagonal( - arrow, - otherEndpoint, - otherBindableElement, - startDragged ? "end" : "start", - elementsMap, - ) || otherEndpoint, - } - : { mode: undefined }; + ? { + mode: "orbit", + element: otherBindableElement, + focusPoint: + projectFixedPointOntoDiagonal( + arrow, + otherEndpoint, + otherBindableElement, + startDragged ? "end" : "start", + elementsMap, + appState.zoom, + ) || otherEndpoint, + } + : { mode: undefined } + : { mode: undefined }; return { start: startDragged ? current : other, @@ -1080,7 +1103,7 @@ export const updateBoundElements = ( }); } - boundElementsVisitor(elementsMap, changedElement, (element) => { + const visitor = (element: ExcalidrawElement | undefined) => { if (!isArrowElement(element) || element.isDeleted) { return; } @@ -1152,7 +1175,71 @@ export const updateBoundElements = ( if (boundText && !boundText.isDeleted) { handleBindTextResize(element, scene, false); } - }); + }; + + boundElementsVisitor(elementsMap, changedElement, visitor); +}; + +const updateArrowBindings = ( + latestElement: ExcalidrawArrowElement, + startOrEnd: "startBinding" | "endBinding", + elementsMap: NonDeletedSceneElementsMap, + scene: Scene, + appState: AppState, +) => { + invariant( + !isElbowArrow(latestElement), + "Elbow arrows not supported for indirect updates", + ); + + const binding = latestElement[startOrEnd]; + const bindableElement = + binding && + (elementsMap.get(binding.elementId) as ExcalidrawBindableElement); + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + latestElement, + startOrEnd === "startBinding" ? 0 : -1, + elementsMap, + ); + const hit = + bindableElement && + hitElementItself({ + element: bindableElement, + point, + elementsMap, + threshold: maxBindingDistance_simple(appState.zoom), + }); + const strategyName = startOrEnd === "startBinding" ? "start" : "end"; + unbindBindingElement(latestElement, strategyName, scene); + if (hit) { + const pointIdx = + startOrEnd === "startBinding" ? 0 : latestElement.points.length - 1; + const localPoint = latestElement.points[pointIdx]; + const strategy = + getBindingStrategyForDraggingBindingElementEndpoints_simple( + latestElement, + new Map([[pointIdx, { point: localPoint }]]), + point[0], + point[1], + elementsMap, + scene.getNonDeletedElements(), + appState, + ); + if ( + strategy[strategyName] && + strategy[strategyName].element?.id === bindableElement.id && + strategy[strategyName].mode + ) { + bindBindingElement( + latestElement, + bindableElement, + strategy[strategyName].mode, + strategyName, + scene, + strategy[strategyName].focusPoint, + ); + } + } }; export const updateBindings = ( @@ -1165,14 +1252,27 @@ export const updateBindings = ( }, ) => { if (isArrowElement(latestElement)) { - bindOrUnbindBindingElement( - latestElement, - new Map(), - Infinity, - Infinity, - scene, - appState, - ); + const elementsMap = scene.getNonDeletedElementsMap(); + + if (latestElement.startBinding) { + updateArrowBindings( + latestElement, + "startBinding", + elementsMap, + scene, + appState, + ); + } + + if (latestElement.endBinding) { + updateArrowBindings( + latestElement, + "endBinding", + elementsMap, + scene, + appState, + ); + } } else { updateBoundElements(latestElement, scene, { ...options, @@ -1286,14 +1386,16 @@ export const bindPointToSnapToElementOutline = ( headingForPointFromElement(bindableElement, aabb, point), ); const snapPoint = snapToMid( - arrowElement, bindableElement, elementsMap, edgePoint, + 0.05, + arrowElement, ); + const resolved = snapPoint || point; const otherPoint = pointFrom( - isHorizontal ? bindableCenter[0] : snapPoint[0], - !isHorizontal ? bindableCenter[1] : snapPoint[1], + isHorizontal ? bindableCenter[0] : resolved[0], + !isHorizontal ? bindableCenter[1] : resolved[1], ); const intersector = customIntersector ?? @@ -1301,7 +1403,7 @@ export const bindPointToSnapToElementOutline = ( otherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, otherPoint)), + vectorNormalize(vectorFromPoint(resolved, otherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), otherPoint, @@ -1316,14 +1418,14 @@ export const bindPointToSnapToElementOutline = ( if (!intersection) { const anotherPoint = pointFrom( - !isHorizontal ? bindableCenter[0] : snapPoint[0], - isHorizontal ? bindableCenter[1] : snapPoint[1], + !isHorizontal ? bindableCenter[0] : resolved[0], + isHorizontal ? bindableCenter[1] : resolved[1], ); const anotherIntersector = lineSegment( anotherPoint, pointFromVector( vectorScale( - vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), + vectorNormalize(vectorFromPoint(resolved, anotherPoint)), Math.max(bindableElement.width, bindableElement.height) * 2, ), anotherPoint, @@ -1470,18 +1572,18 @@ export const avoidRectangularCorner = ( return p; }; -const snapToMid = ( - arrowElement: ExcalidrawArrowElement, +export const snapToMid = ( bindTarget: ExcalidrawBindableElement, elementsMap: ElementsMap, p: GlobalPoint, tolerance: number = 0.05, -): GlobalPoint => { + arrowElement?: ExcalidrawArrowElement, +): GlobalPoint | undefined => { const { x, y, width, height, angle } = bindTarget; const center = elementCenterPoint(bindTarget, elementsMap, -0.1, -0.1); const nonRotated = pointRotateRads(p, center, -angle as Radians); - const bindingGap = getBindingGap(bindTarget, arrowElement); + const bindingGap = arrowElement ? getBindingGap(bindTarget, arrowElement) : 0; // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -1490,7 +1592,7 @@ const snapToMid = ( // Too close to the center makes it hard to resolve direction precisely if (pointDistance(center, nonRotated) < bindingGap) { - return p; + return undefined; } if ( @@ -1499,8 +1601,8 @@ const snapToMid = ( nonRotated[1] < center[1] + verticalThreshold ) { // LEFT - return pointRotateRads( - pointFrom(x - bindingGap, center[1]), + return pointRotateRads( + pointFrom(x - bindingGap, center[1]), center, angle, ); @@ -1510,7 +1612,11 @@ const snapToMid = ( nonRotated[0] < center[0] + horizontalThreshold ) { // TOP - return pointRotateRads(pointFrom(center[0], y - bindingGap), center, angle); + return pointRotateRads( + pointFrom(center[0], y - bindingGap), + center, + angle, + ); } else if ( nonRotated[0] >= x + width / 2 && nonRotated[1] > center[1] - verticalThreshold && @@ -1518,7 +1624,7 @@ const snapToMid = ( ) { // RIGHT return pointRotateRads( - pointFrom(x + width + bindingGap, center[1]), + pointFrom(x + width + bindingGap, center[1]), center, angle, ); @@ -1529,7 +1635,7 @@ const snapToMid = ( ) { // DOWN return pointRotateRads( - pointFrom(center[0], y + height + bindingGap), + pointFrom(center[0], y + height + bindingGap), center, angle, ); @@ -1578,13 +1684,44 @@ const snapToMid = ( } } - return p; + return undefined; }; -const compareElementArea = ( - a: ExcalidrawBindableElement, - b: ExcalidrawBindableElement, -) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); +const extractBinding = ( + arrow: ExcalidrawArrowElement, + startOrEnd: "startBinding" | "endBinding", + elementsMap: ElementsMap, +) => { + const binding = arrow[startOrEnd]; + if (!binding) { + return { + element: null, + fixedPoint: null, + focusPoint: null, + binding, + mode: null, + }; + } + + const element = elementsMap.get( + binding.elementId, + ) as ExcalidrawBindableElement; + + return { + element, + fixedPoint: binding.fixedPoint, + focusPoint: getGlobalFixedPointForBindableElement( + normalizeFixedPoint(binding.fixedPoint), + element, + elementsMap, + ), + binding, + mode: binding.mode, + }; +}; + +const elementArea = (element: ExcalidrawBindableElement) => + element.width * element.height; export const updateBoundPoint = ( arrow: NonDeleted, @@ -1592,7 +1729,7 @@ export const updateBoundPoint = ( binding: FixedPointBinding | null | undefined, bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, - customIntersector?: LineSegment, + dragging?: boolean, ): LocalPoint | null => { if ( binding == null || @@ -1607,152 +1744,136 @@ export const updateBoundPoint = ( return null; } - const global = getGlobalFixedPointForBindableElement( + const focusPoint = getGlobalFixedPointForBindableElement( normalizeFixedPoint(binding.fixedPoint), bindableElement, elementsMap, ); - const pointIndex = - startOrEnd === "startBinding" ? 0 : arrow.points.length - 1; - const elbowed = isElbowArrow(arrow); - const otherBinding = - startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding; - const otherBindableElement = - otherBinding && - (elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement); - const bounds = getElementBounds(bindableElement, elementsMap); - const otherBounds = - otherBindableElement && getElementBounds(otherBindableElement, elementsMap); - const isLargerThanOther = - otherBindableElement && - compareElementArea(bindableElement, otherBindableElement) < - // if both shapes the same size, pretend the other is larger - (startOrEnd === "endBinding" ? 1 : 0); - const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds); - // GOAL: If the arrow becomes too short, we want to jump the arrow endpoints - // to the exact focus points on the elements. - // INTUITION: We're not interested in the exacts length of the arrow (which - // will change if we change where we route it), we want to know the length of - // the part which lies outside of both shapes and consider that as a trigger - // to change where we point the arrow. Avoids jumping the arrow in and out - // at every frame. - let arrowTooShort = false; - if ( - !isOverlapping && - !elbowed && - arrow.startBinding && - arrow.endBinding && - otherBindableElement && - arrow.points.length === 2 - ) { - const startFocusPoint = getGlobalFixedPointForBindableElement( - arrow.startBinding.fixedPoint, - startOrEnd === "startBinding" ? bindableElement : otherBindableElement, - elementsMap, - ); - const endFocusPoint = getGlobalFixedPointForBindableElement( - arrow.endBinding.fixedPoint, - startOrEnd === "endBinding" ? bindableElement : otherBindableElement, - elementsMap, - ); - const segment = lineSegment(startFocusPoint, endFocusPoint); - const startIntersection = intersectElementWithLineSegment( - startOrEnd === "endBinding" ? bindableElement : otherBindableElement, - elementsMap, - segment, - 0, - true, - ); - const endIntersection = intersectElementWithLineSegment( - startOrEnd === "startBinding" ? bindableElement : otherBindableElement, - elementsMap, - segment, - 0, - true, - ); - if (startIntersection.length > 0 && endIntersection.length > 0) { - const len = pointDistance(startIntersection[0], endIntersection[0]); - arrowTooShort = len < 40; - } - } - - const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther; - - let _customIntersector = customIntersector; - if (!elbowed && !_customIntersector) { - const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords( + // 0. Short-circuit for inside binding as it doesn't require any + // calculations and is not affected by other bindings + if (binding.mode === "inside") { + return LinearElementEditor.createPointAt( arrow, elementsMap, - ); - const center = pointFrom((x1 + x2) / 2, (y1 + y2) / 2); - const edgePoint = isRectanguloidElement(bindableElement) - ? avoidRectangularCorner(arrow, bindableElement, elementsMap, global) - : global; - const adjacentPoint = pointRotateRads( - pointFrom( - arrow.x + - arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0], - arrow.y + - arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1], - ), - center, - arrow.angle as Radians, - ); - const bindingGap = getBindingGap(bindableElement, arrow); - const halfVector = vectorScale( - vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)), - pointDistance(edgePoint, adjacentPoint) + - Math.max(bindableElement.width, bindableElement.height) + - bindingGap * 2, - ); - _customIntersector = lineSegment( - pointFromVector(halfVector, adjacentPoint), - pointFromVector(vectorScale(halfVector, -1), adjacentPoint), + focusPoint[0], + focusPoint[1], + null, ); } - const maybeOutlineGlobal = - binding.mode === "orbit" && bindableElement - ? isNested - ? global - : bindPointToSnapToElementOutline( - { - ...arrow, - points: [ - pointIndex === 0 - ? LinearElementEditor.createPointAt( - arrow, - elementsMap, - global[0], - global[1], - null, - ) - : arrow.points[0], - ...arrow.points.slice(1, -1), - pointIndex === arrow.points.length - 1 - ? LinearElementEditor.createPointAt( - arrow, - elementsMap, - global[0], - global[1], - null, - ) - : arrow.points[arrow.points.length - 1], - ], - }, - bindableElement, - pointIndex === 0 ? "start" : "end", - elementsMap, - _customIntersector, - ) - : global; + const { element: otherBindable, focusPoint: otherFocusPoint } = + extractBinding( + arrow, + startOrEnd === "startBinding" ? "endBinding" : "startBinding", + elementsMap, + ); + const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + startOrEnd === "startBinding" ? -1 : 0, + elementsMap, + ); + const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint; + const intersector = + otherFocusPointOrArrowPoint && + lineSegment(focusPoint, otherFocusPointOrArrowPoint); + const otherOutlinePoint = + otherBindable && + intersector && + intersectElementWithLineSegment( + otherBindable, + elementsMap, + intersector, + getBindingGap(otherBindable, arrow), + ).sort( + (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint), + )[0]; + const outlinePoint = + intersector && + intersectElementWithLineSegment( + bindableElement, + elementsMap, + intersector, + getBindingGap(bindableElement, arrow), + ).sort( + (a, b) => + pointDistanceSq(a, otherFocusPointOrArrowPoint) - + pointDistanceSq(b, otherFocusPointOrArrowPoint), + )[0]; + const startHasArrowhead = arrow.startArrowhead !== null; + const endHasArrowhead = arrow.endArrowhead !== null; + const resolvedTarget = + (!startHasArrowhead && !endHasArrowhead) || + (startOrEnd === "startBinding" && startHasArrowhead) || + (startOrEnd === "endBinding" && endHasArrowhead) + ? focusPoint + : outlinePoint || focusPoint; + // 1. Handle case when the outline point (or focus point) is inside + // the other shape by short-circuiting to the focus point, otherwise + // the arrow would invert + if ( + otherBindable && + outlinePoint && + !dragging && + // Arbitrary threshold to handle wireframing use cases + elementArea(otherBindable) < elementArea(bindableElement) * 2 && + hitElementItself({ + element: otherBindable, + point: outlinePoint, + elementsMap, + threshold: getBindingGap(otherBindable, arrow), + overrideShouldTestInside: true, + }) + ) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + resolvedTarget[0], + resolvedTarget[1], + null, + ); + } + + const otherTargetPoint = otherBindable + ? otherOutlinePoint || otherFocusPoint || otherArrowPoint + : otherArrowPoint; + const arrowTooShort = + pointDistance(otherTargetPoint, outlinePoint || focusPoint) <= + BASE_ARROW_MIN_LENGTH; + + // 2. If the arrow is unconnected at the other end, just check arrow size + // and short-circuit to the focus point if the arrow is too short to + // avoid inversion + if (!otherBindable) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0], + arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1], + null, + ); + } + + // 3. If the arrow is too short while connected on both ends and + // the other arrow endpoint will not be inside the bindable, just + // check the arrow size and make a decision based on that + if (arrowTooShort) { + return LinearElementEditor.createPointAt( + arrow, + elementsMap, + resolvedTarget?.[0] || focusPoint[0], + resolvedTarget?.[1] || focusPoint[1], + null, + ); + } + + // 4. In the general case, snap to the outline if possible return LinearElementEditor.createPointAt( arrow, elementsMap, - maybeOutlineGlobal[0], - maybeOutlineGlobal[1], + outlinePoint?.[0] || focusPoint[0], + outlinePoint?.[1] || focusPoint[1], null, ); }; @@ -1802,7 +1923,7 @@ export const calculateFixedPointForNonElbowArrowBinding = ( elementsMap: ElementsMap, focusPoint?: GlobalPoint, ): { fixedPoint: FixedPoint } => { - const edgePoint = focusPoint + const edgePoint: GlobalPoint = focusPoint ? focusPoint : LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, @@ -1810,11 +1931,7 @@ export const calculateFixedPointForNonElbowArrowBinding = ( elementsMap, ); - // Convert the global point to element-local coordinates - const elementCenter = pointFrom( - hoveredElement.x + hoveredElement.width / 2, - hoveredElement.y + hoveredElement.height / 2, - ); + const elementCenter = elementCenterPoint(hoveredElement, elementsMap); // Rotate the point to account for element rotation const nonRotatedPoint = pointRotateRads( @@ -2324,21 +2441,37 @@ export const getArrowLocalFixedPoints = ( ]; }; -export const normalizeFixedPoint = ( +export const isFixedPoint = ( + fixedPoint: any, +): fixedPoint is FixedPointBinding["fixedPoint"] => { + return ( + Array.isArray(fixedPoint) && + fixedPoint.length === 2 && + fixedPoint.every((coord) => Number.isFinite(coord)) + ); +}; + +export const normalizeFixedPoint = ( fixedPoint: T, -): T extends null ? null : FixedPoint => { +): FixedPoint => { + if (!isFixedPoint(fixedPoint)) { + return [0.5001, 0.5001]; + } + + const EPSILON = 0.0001; + // Do not allow a precise 0.5 for fixed point ratio // to avoid jumping arrow heading due to floating point imprecision if ( - fixedPoint && - (Math.abs(fixedPoint[0] - 0.5) < 0.0001 || - Math.abs(fixedPoint[1] - 0.5) < 0.0001) + Math.abs(fixedPoint[0] - 0.5) < EPSILON || + Math.abs(fixedPoint[1] - 0.5) < EPSILON ) { return fixedPoint.map((ratio) => - Math.abs(ratio - 0.5) < 0.0001 ? 0.5001 : ratio, - ) as T extends null ? null : FixedPoint; + Math.abs(ratio - 0.5) < EPSILON ? 0.5001 : ratio, + ) as FixedPoint; } - return fixedPoint as any as T extends null ? null : FixedPoint; + + return fixedPoint; }; type Side = diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index a96c3ebc14..46d2dbd46a 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -59,8 +59,11 @@ import { LinearElementEditor } from "./linearElementEditor"; import { distanceToElement } from "./distance"; +import { getBindingGap } from "./binding"; + import type { ElementsMap, + ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, @@ -105,6 +108,12 @@ export type HitTestArgs = { overrideShouldTestInside?: boolean; }; +let cachedPoint: GlobalPoint | null = null; +let cachedElement: WeakRef | null = null; +let cachedThreshold: number = Infinity; +let cachedHit: boolean = false; +let cachedOverrideShouldTestInside = false; + export const hitElementItself = ({ point, element, @@ -113,6 +122,24 @@ export const hitElementItself = ({ frameNameBound = null, overrideShouldTestInside = false, }: HitTestArgs) => { + // Return cached result if the same point and element version is tested again + if ( + cachedPoint && + pointsEqual(point, cachedPoint) && + cachedThreshold <= threshold && + overrideShouldTestInside === cachedOverrideShouldTestInside + ) { + const derefElement = cachedElement?.deref(); + if ( + derefElement && + derefElement.id === element.id && + derefElement.version === element.version && + derefElement.versionNonce === element.versionNonce + ) { + return cachedHit; + } + } + // Hit test against a frame's name const hitFrameName = frameNameBound ? isPointWithinBounds( @@ -153,7 +180,16 @@ export const hitElementItself = ({ isPointOnElementOutline(point, element, elementsMap, threshold) : isPointOnElementOutline(point, element, elementsMap, threshold); - return hitElement || hitFrameName; + const result = hitElement || hitFrameName; + + // Cache end result + cachedPoint = point; + cachedElement = new WeakRef(element); + cachedThreshold = threshold; + cachedOverrideShouldTestInside = overrideShouldTestInside; + cachedHit = result; + + return result; }; export const hitElementBoundingBox = ( @@ -257,7 +293,7 @@ export const getAllHoveredElementAtPoint = ( point: Readonly, elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, - toleranceFn?: (element: ExcalidrawBindableElement) => number, + tolerance?: number, ): NonDeleted[] => { const candidateElements: NonDeleted[] = []; // We need to to hit testing from front (end of the array) to back (beginning of the array) @@ -273,7 +309,7 @@ export const getAllHoveredElementAtPoint = ( if ( isBindableElement(element, false) && - bindingBorderTest(element, point, elementsMap, toleranceFn?.(element)) + bindingBorderTest(element, point, elementsMap, tolerance) ) { candidateElements.push(element); @@ -290,13 +326,13 @@ export const getHoveredElementForBinding = ( point: Readonly, elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, - toleranceFn?: (element: ExcalidrawBindableElement) => number, + tolerance?: number, ): NonDeleted | null => { const candidateElements = getAllHoveredElementAtPoint( point, elements, elementsMap, - toleranceFn, + tolerance, ); if (!candidateElements || candidateElements.length === 0) { @@ -315,6 +351,56 @@ export const getHoveredElementForBinding = ( .pop() as NonDeleted; }; +export const getHoveredElementForFocusPoint = ( + point: GlobalPoint, + arrow: ExcalidrawArrowElement, + elements: readonly Ordered[], + elementsMap: NonDeletedSceneElementsMap, + tolerance?: number, +): ExcalidrawBindableElement | null => { + const candidateElements: NonDeleted[] = []; + // We need to to hit testing from front (end of the array) to back (beginning of the array) + // because array is ordered from lower z-index to highest and we want element z-index + // with higher z-index + for (let index = elements.length - 1; index >= 0; --index) { + const element = elements[index]; + + invariant( + !element.isDeleted, + "Elements in the function parameter for getAllElementsAtPositionForBinding() should not contain deleted elements", + ); + + if ( + isBindableElement(element, false) && + bindingBorderTest(element, point, elementsMap, tolerance) + ) { + candidateElements.push(element); + } + } + + if (!candidateElements || candidateElements.length === 0) { + return null; + } + + if (candidateElements.length === 1) { + return candidateElements[0]; + } + + const distanceFilteredCandidateElements = candidateElements + // Resolve by distance + .filter( + (el) => + distanceToElement(el, elementsMap, point) <= getBindingGap(el, arrow) || + isPointInElement(point, el, elementsMap), + ); + + if (distanceFilteredCandidateElements.length === 0) { + return null; + } + + return distanceFilteredCandidateElements[0] as NonDeleted; +}; + /** * Intersect a line with an element for binding test * diff --git a/packages/element/src/distribute.ts b/packages/element/src/distribute.ts index add3522acc..bb9c2708d2 100644 --- a/packages/element/src/distribute.ts +++ b/packages/element/src/distribute.ts @@ -1,10 +1,12 @@ import type { AppState } from "@excalidraw/excalidraw/types"; +import { updateBoundElements } from "./binding"; import { getCommonBoundingBox } from "./bounds"; -import { newElementWith } from "./mutateElement"; import { getSelectedElementsByGroup } from "./groups"; +import type { Scene } from "./Scene"; + import type { ElementsMap, ExcalidrawElement } from "./types"; export interface Distribution { @@ -17,6 +19,7 @@ export const distributeElements = ( elementsMap: ElementsMap, distribution: Distribution, appState: Readonly, + scene: Scene, ): ExcalidrawElement[] => { const [start, mid, end, extent] = distribution.axis === "x" @@ -66,12 +69,16 @@ export const distributeElements = ( translation[distribution.axis] = pos - box[mid]; } - return group.map((element) => - newElementWith(element, { + return group.map((element) => { + const updatedElement = scene.mutateElement(element, { x: element.x + translation.x, y: element.y + translation.y, - }), - ); + }); + updateBoundElements(element, scene, { + simultaneouslyUpdated: group, + }); + return updatedElement; + }); }); } @@ -90,11 +97,15 @@ export const distributeElements = ( pos += step; pos += box[extent]; - return group.map((element) => - newElementWith(element, { + return group.map((element) => { + const updatedElement = scene.mutateElement(element, { x: element.x + translation.x, y: element.y + translation.y, - }), - ); + }); + updateBoundElements(element, scene, { + simultaneouslyUpdated: group, + }); + return updatedElement; + }); }); }; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index c531a98a04..3be312cefd 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -2277,7 +2277,10 @@ const getHoveredElement = ( elements: readonly Ordered[], zoom?: AppState["zoom"], ) => { - return getHoveredElementForBinding(origPoint, elements, elementsMap, () => + return getHoveredElementForBinding( + origPoint, + elements, + elementsMap, maxBindingDistance_simple(zoom), ); }; diff --git a/packages/element/src/embeddable.ts b/packages/element/src/embeddable.ts index 153ec72bd0..89489720af 100644 --- a/packages/element/src/embeddable.ts +++ b/packages/element/src/embeddable.ts @@ -56,7 +56,7 @@ const RE_REDDIT = const RE_REDDIT_EMBED = /^ { +const parseYouTubeLikeTimestamp = (url: string): number => { let timeParam: string | null | undefined; try { @@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => { return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); }; +const parseGoogleDriveVideoLink = ( + url: string, +): { fileId: string; resourceKey?: string; timestamp?: number } | null => { + try { + const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`); + const hostname = urlObj.hostname.replace(/^www\./, ""); + if (hostname !== "drive.google.com") { + return null; + } + + let fileId: string | null = null; + const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/); + if (pathMatch?.[1]) { + fileId = pathMatch[1]; + } else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") { + // Shared Drive links can be emitted as: + // - /open?id= (common "open in Drive" format) + // - /uc?...&id= (download/export endpoint often seen in copied links) + fileId = urlObj.searchParams.get("id"); + } + + if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) { + return null; + } + + // Some Drive share links include `resourcekey` for access to link-shared + // files; preserve it in the preview URL so embeds keep working. + const resourceKey = urlObj.searchParams.get("resourcekey"); + const timestamp = parseYouTubeLikeTimestamp(urlObj.toString()); + + return { + fileId, + resourceKey: + resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey) + ? resourceKey + : undefined, + // Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`); + // normalize to seconds for a stable preview URL. + timestamp: timestamp > 0 ? timestamp : undefined, + }; + } catch (error) { + return null; + } +}; + const ALLOWED_DOMAINS = new Set([ "youtube.com", "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "link.excalidraw.com", "gist.github.com", @@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([ "youtu.be", "vimeo.com", "player.vimeo.com", + "drive.google.com", "figma.com", "twitter.com", "x.com", @@ -142,7 +189,7 @@ export const getEmbedLink = ( let aspectRatio = { w: 560, h: 840 }; const ytLink = link.match(RE_YOUTUBE); if (ytLink?.[2]) { - const startTime = parseYouTubeTimestamp(originalLink); + const startTime = parseYouTubeLikeTimestamp(originalLink); const time = startTime > 0 ? `&start=${startTime}` : ``; const isPortrait = link.includes("shorts"); type = "video"; @@ -201,6 +248,36 @@ export const getEmbedLink = ( }; } + const googleDriveVideo = parseGoogleDriveVideoLink(link); + if (googleDriveVideo) { + type = "video"; + const searchParams = new URLSearchParams(); + if (googleDriveVideo.resourceKey) { + searchParams.set("resourcekey", googleDriveVideo.resourceKey); + } + if (googleDriveVideo.timestamp) { + searchParams.set("t", `${googleDriveVideo.timestamp}`); + } + + const search = searchParams.toString(); + link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${ + search ? `?${search}` : "" + }`; + aspectRatio = { w: 560, h: 315 }; + embeddedLinkCache.set(originalLink, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; + } + const figmaLink = link.match(RE_FIGMA); if (figmaLink) { type = "generic"; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 381cc733bf..1ca1c1a289 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -70,6 +70,7 @@ export * from "./elbowArrow"; export * from "./elementLink"; export * from "./embeddable"; export * from "./flowchart"; +export * from "./arrows/focus"; export * from "./fractionalIndex"; export * from "./frame"; export * from "./groups"; @@ -97,3 +98,4 @@ export * from "./transformHandles"; export * from "./typeChecks"; export * from "./utils"; export * from "./zindex"; +export * from "./arrows/helpers"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 4a6bc798d0..754f1d03d5 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -10,7 +10,6 @@ import { vectorFromPoint, curveLength, curvePointAtLength, - lineSegment, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -27,6 +26,7 @@ import { import { deconstructLinearOrFreeDrawElement, + getSnapOutlineMidPoint, isPathALoop, moveArrowAboveBindable, projectFixedPointOntoDiagonal, @@ -49,6 +49,7 @@ import { calculateFixedPointForNonElbowArrowBinding, getBindingStrategyForDraggingBindingElementEndpoints, isBindingEnabled, + snapToMid, updateBoundPoint, } from "./binding"; import { @@ -150,6 +151,8 @@ export class LinearElementEditor { public readonly pointerOffset: Readonly<{ x: number; y: number }>; public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: GlobalPoint | null; + public readonly hoveredFocusPointBinding: "start" | "end" | null; + public readonly draggedFocusPointBinding: "start" | "end" | null; public readonly elbowed: boolean; public readonly customLineAngle: number | null; public readonly isEditing: boolean; @@ -195,6 +198,8 @@ export class LinearElementEditor { }; this.hoverPointIndex = -1; this.segmentMidPointHoveredCoords = null; + this.hoveredFocusPointBinding = null; + this.draggedFocusPointBinding = null; this.elbowed = isElbowArrow(element) && element.elbowed; this.customLineAngle = null; this.isEditing = isEditing; @@ -352,6 +357,7 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event), event.altKey, + linearElementEditor, ); LinearElementEditor.movePoints(element, app.scene, positions, { @@ -405,13 +411,14 @@ export class LinearElementEditor { altFocusPoint: !linearElementEditor.initialState.altFocusPoint && startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id + updates?.suggestedBinding?.element.id !== startBindingElement.id ? projectFixedPointOntoDiagonal( element, pointFrom(element.x, element.y), startBindingElement, "start", elementsMap, + app.state.zoom, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -529,6 +536,7 @@ export class LinearElementEditor { app, shouldRotateWithDiscreteAngle(event) && singlePointDragged, event.altKey, + linearElementEditor, ); LinearElementEditor.movePoints(element, app.scene, positions, { @@ -604,13 +612,13 @@ export class LinearElementEditor { const altFocusPointBindableElement = endIsSelected && // The "other" end (i.e. "end") is dragged startBindingElement && - updates?.suggestedBinding?.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap + updates?.suggestedBinding?.element.id !== startBindingElement.id // The end point is not hovering the start bindable + it's binding gap ? startBindingElement : startIsSelected && // The "other" end (i.e. "start") is dragged - endBindingElement && - updates?.suggestedBinding?.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap - ? endBindingElement - : null; + endBindingElement && + updates?.suggestedBinding?.element.id !== endBindingElement.id // The start point is not hovering the end bindable + it's binding gap + ? endBindingElement + : null; const newLinearElementEditor: LinearElementEditor = { ...linearElementEditor, @@ -628,6 +636,7 @@ export class LinearElementEditor { altFocusPointBindableElement, "start", elementsMap, + app.state.zoom, ) : linearElementEditor.initialState.altFocusPoint, }, @@ -725,7 +734,6 @@ export class LinearElementEditor { ? [pointerDownState.lastClickedPoint] : selectedPointsIndices, isDragging: false, - pointerOffset: { x: 0, y: 0 }, customLineAngle: null, initialState: { ...editingLinearElement.initialState, @@ -2078,6 +2086,7 @@ const pointDraggingUpdates = ( app: AppClassProperties, angleLocked: boolean, altKey: boolean, + linearElementEditor: LinearElementEditor, ): { positions: PointsPositionUpdates; updates?: PointMoveOtherUpdates; @@ -2125,18 +2134,89 @@ const pointDraggingUpdates = ( ); if (isElbowArrow(element)) { + const suggestedBindingElement = startIsDragged + ? start.element + : endIsDragged + ? end.element + : null; + return { positions: naiveDraggingPoints, updates: { - suggestedBinding: startIsDragged - ? start.element - : endIsDragged - ? end.element - : null, + suggestedBinding: suggestedBindingElement + ? { + element: suggestedBindingElement, + midPoint: snapToMid( + suggestedBindingElement, + elementsMap, + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + ), + } + : null, }, }; } + // Handle the case where neither endpoint is being dragged + // but we need to update bound endpoints + if (!startIsDragged && !endIsDragged) { + const nextArrow = { + ...element, + points: element.points.map((p, idx) => { + return naiveDraggingPoints.get(idx)?.point ?? p; + }), + }; + const positions = new Map(naiveDraggingPoints); + + if (element.startBinding) { + const startBindable = elementsMap.get(element.startBinding.elementId) as + | ExcalidrawBindableElement + | undefined; + if (startBindable) { + const startPoint = + updateBoundPoint( + nextArrow, + "startBinding", + element.startBinding, + startBindable, + elementsMap, + ) ?? null; + if (startPoint) { + positions.set(0, { point: startPoint, isDragging: true }); + } + } + } + + if (element.endBinding) { + const endBindable = elementsMap.get(element.endBinding.elementId) as + | ExcalidrawBindableElement + | undefined; + if (endBindable) { + const endPoint = + updateBoundPoint( + nextArrow, + "endBinding", + element.endBinding, + endBindable, + elementsMap, + ) ?? null; + if (endPoint) { + positions.set(element.points.length - 1, { + point: endPoint, + isDragging: true, + }); + } + } + } + + return { + positions, + }; + } + if (startIsDragged === endIsDragged) { return { positions: naiveDraggingPoints, @@ -2167,7 +2247,20 @@ const pointDraggingUpdates = ( (updates.startBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = start.element; + updates.suggestedBinding = start.element + ? { + element: start.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + start.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (startIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; @@ -2193,7 +2286,20 @@ const pointDraggingUpdates = ( (updates.endBinding.mode === "orbit" || !getFeatureFlag("COMPLEX_BINDINGS")) ) { - updates.suggestedBinding = end.element; + updates.suggestedBinding = end.element + ? { + element: end.element, + midPoint: getSnapOutlineMidPoint( + pointFrom( + scenePointerX - linearElementEditor.pointerOffset.x, + scenePointerY - linearElementEditor.pointerOffset.y, + ), + end.element, + elementsMap, + app.state.zoom, + ), + } + : null; } } else if (endIsDragged) { updates.suggestedBinding = app.state.suggestedBinding; @@ -2233,19 +2339,6 @@ const pointDraggingUpdates = ( : updates.endBinding, }; - // We need to use a custom intersector to ensure that if there is a big "jump" - // in the arrow's position, we can position it with outline avoidance - // pixel-perfectly and avoid "dancing" arrows. - // NOTE: Direction matters here, so we create two intersectors - const startCustomIntersector = - start.focusPoint && end.focusPoint - ? lineSegment(start.focusPoint, end.focusPoint) - : undefined; - const endCustomIntersector = - start.focusPoint && end.focusPoint - ? lineSegment(end.focusPoint, start.focusPoint) - : undefined; - // Needed to handle a special case where an existing arrow is dragged over // the same element it is bound to on the other side const startIsDraggingOverEndElement = @@ -2271,19 +2364,19 @@ const pointDraggingUpdates = ( const endLocalPoint = startIsDraggingOverEndElement ? nextArrow.points[nextArrow.points.length - 1] : endIsDraggingOverStartElement && - app.state.bindMode !== "inside" && - getFeatureFlag("COMPLEX_BINDINGS") - ? nextArrow.points[0] - : endBindable - ? updateBoundPoint( - element, - "endBinding", - nextArrow.endBinding, - endBindable, - elementsMap, - endCustomIntersector, - ) || nextArrow.points[nextArrow.points.length - 1] - : nextArrow.points[nextArrow.points.length - 1]; + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? nextArrow.points[0] + : endBindable + ? updateBoundPoint( + element, + "endBinding", + nextArrow.endBinding, + endBindable, + elementsMap, + endIsDragged, + ) || nextArrow.points[nextArrow.points.length - 1] + : nextArrow.points[nextArrow.points.length - 1]; // We need to keep the simulated next arrow up-to-date, because // updateBoundPoint looks at the opposite point @@ -2302,25 +2395,28 @@ const pointDraggingUpdates = ( endIsDraggingOverStartElement && getFeatureFlag("COMPLEX_BINDINGS") ? nextArrow.points[0] : startIsDraggingOverEndElement && - app.state.bindMode !== "inside" && - getFeatureFlag("COMPLEX_BINDINGS") - ? nextArrow.points[nextArrow.points.length - 1] - : startBindable - ? updateBoundPoint( - element, - "startBinding", - nextArrow.startBinding, - startBindable, - elementsMap, - startCustomIntersector, - ) || nextArrow.points[0] - : nextArrow.points[0]; + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ? endLocalPoint + : startBindable + ? updateBoundPoint( + element, + "startBinding", + nextArrow.startBinding, + startBindable, + elementsMap, + startIsDragged, + ) || nextArrow.points[0] + : nextArrow.points[0]; const endChanged = - pointDistance( - endLocalPoint, - nextArrow.points[nextArrow.points.length - 1], - ) !== 0; + !startIsDraggingOverEndElement && + !( + endIsDraggingOverStartElement && + app.state.bindMode !== "inside" && + getFeatureFlag("COMPLEX_BINDINGS") + ) && + !!endBindable; const startChanged = pointDistance(startLocalPoint, nextArrow.points[0]) !== 0; @@ -2334,13 +2430,7 @@ const pointDraggingUpdates = ( const indices = Array.from(indicesSet); return { - updates: - updates.startBinding || updates.suggestedBinding - ? { - startBinding: updates.startBinding, - suggestedBinding: updates.suggestedBinding, - } - : undefined, + updates, positions: new Map( indices.map((idx) => { return [ diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 060e232084..f4bea18647 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -23,6 +23,7 @@ import { getVerticalOffset, invariant, applyDarkModeFilter, + isSafari, } from "@excalidraw/common"; import type { @@ -360,8 +361,9 @@ IMAGE_ERROR_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( const drawImagePlaceholder = ( element: ExcalidrawImageElement, context: CanvasRenderingContext2D, + theme: StaticCanvasRenderConfig["theme"], ) => { - context.fillStyle = "#E7E7E7"; + context.fillStyle = theme === THEME.DARK ? "#2E2E2E" : "#E7E7E7"; context.fillRect(0, 0, element.width, element.height); const imageMinWidthOrHeight = Math.min(element.width, element.height); @@ -443,13 +445,6 @@ const drawElementOnCanvas = ( ? cacheEntry?.image : undefined; - const shouldInvertImage = - renderConfig.theme === THEME.DARK && - cacheEntry?.mimeType === MIME_TYPES.svg; - - if (shouldInvertImage) { - context.filter = DARK_THEME_FILTER; - } if (img != null && !(img instanceof Promise)) { if (element.roundness && context.roundRect) { context.beginPath(); @@ -472,19 +467,78 @@ const drawElementOnCanvas = ( height: img.naturalHeight, }; - context.drawImage( - img, - x, - y, - width, - height, - 0 /* hardcoded for the selection box*/, - 0, - element.width, - element.height, - ); + const shouldInvertImage = + renderConfig.theme === THEME.DARK && + cacheEntry?.mimeType === MIME_TYPES.svg; + + if (shouldInvertImage && isSafari) { + const devicePixelRatio = window.devicePixelRatio || 1; + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = element.width * devicePixelRatio; + tempCanvas.height = element.height * devicePixelRatio; + const tempContext = tempCanvas.getContext("2d"); + + if (tempContext) { + tempContext.scale(devicePixelRatio, devicePixelRatio); + tempContext.drawImage( + img, + x, + y, + width, + height, + 0, + 0, + element.width, + element.height, + ); + + const imageData = tempContext.getImageData( + 0, + 0, + tempCanvas.width, + tempCanvas.height, + ); + + const data = imageData.data; + + for (let i = 0; i < data.length; i += 4) { + data[i] = 255 - data[i]; + data[i + 1] = 255 - data[i + 1]; + data[i + 2] = 255 - data[i + 2]; + } + + tempContext.putImageData(imageData, 0, 0); + context.drawImage( + tempCanvas, + 0, + 0, + tempCanvas.width, + tempCanvas.height, + 0, + 0, + element.width, + element.height, + ); + } + } else { + if (shouldInvertImage) { + context.filter = DARK_THEME_FILTER; + } + + context.drawImage( + img, + x, + y, + width, + height, + 0 /* hardcoded for the selection box*/, + 0, + element.width, + element.height, + ); + } } else { - drawImagePlaceholder(element, context); + drawImagePlaceholder(element, context, renderConfig.theme); } context.restore(); break; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index c8e6889864..7013acf2f1 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -7,6 +7,7 @@ import { } from "@excalidraw/common"; import { + bezierEquation, curve, curveCatmullRomCubicApproxPoints, curveOffsetPoints, @@ -27,19 +28,30 @@ import { import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math"; -import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types"; +import type { + AppState, + NormalizedZoomValue, + Zoom, +} from "@excalidraw/excalidraw/types"; import { elementCenterPoint, getDiamondPoints } from "./bounds"; import { generateLinearCollisionShape } from "./shape"; -import { isPointInElement } from "./collision"; +import { hitElementItself, isPointInElement } from "./collision"; import { LinearElementEditor } from "./linearElementEditor"; import { isRectangularElement } from "./typeChecks"; +import { maxBindingDistance_simple } from "./binding"; + +import { + getGlobalFixedPointForBindableElement, + normalizeFixedPoint, +} from "./binding"; import type { ElementsMap, ExcalidrawArrowElement, + ExcalidrawBindableElement, ExcalidrawDiamondElement, ExcalidrawElement, ExcalidrawFreeDrawElement, @@ -329,24 +341,10 @@ export function deconstructRectanguloidElement( return shape; } -/** - * Get the **unrotated** building components of a diamond element - * in the form of line segments and curves as a tuple, in this order. - * - * @param element The element to deconstruct - * @param offset An optional offset - * @returns Tuple of line **unrotated** segments (0) and curves (1) - */ -export function deconstructDiamondElement( +export function getDiamondBaseCorners( element: ExcalidrawDiamondElement, offset: number = 0, -): [LineSegment[], Curve[]] { - const cachedShape = getElementShapesCacheEntry(element, offset); - - if (cachedShape) { - return cachedShape; - } - +): Curve[] { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = getDiamondPoints(element); const verticalRadius = element.roundness @@ -363,7 +361,7 @@ export function deconstructDiamondElement( pointFrom(element.x + leftX, element.y + leftY), ]; - const baseCorners = [ + return [ curve( pointFrom( right[0] - verticalRadius, @@ -413,6 +411,27 @@ export function deconstructDiamondElement( ), ), // TOP ]; +} + +/** + * Get the **unrotated** building components of a diamond element + * in the form of line segments and curves as a tuple, in this order. + * + * @param element The element to deconstruct + * @param offset An optional offset + * @returns Tuple of line **unrotated** segments (0) and curves (1) + */ +export function deconstructDiamondElement( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const cachedShape = getElementShapesCacheEntry(element, offset); + + if (cachedShape) { + return cachedShape; + } + + const baseCorners = getDiamondBaseCorners(element, offset); const corners = baseCorners.map( (corner) => @@ -570,28 +589,128 @@ const getDiagonalsForBindableElement = ( return [diagonalOne, diagonalTwo]; }; +export const getSnapOutlineMidPoint = ( + point: GlobalPoint, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, + zoom: AppState["zoom"], +) => { + const center = elementCenterPoint(element, elementsMap); + const sideMidpoints = + element.type === "diamond" + ? getDiamondBaseCorners(element).map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + + return pointFrom(rotatedPoint[0], rotatedPoint[1]); + }) + : [ + // RIGHT midpoint + pointRotateRads( + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + center, + element.angle, + ), + // BOTTOM midpoint + pointRotateRads( + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + center, + element.angle, + ), + // LEFT midpoint + pointRotateRads( + pointFrom(element.x, element.y + element.height / 2), + center, + element.angle, + ), + // TOP midpoint + pointRotateRads( + pointFrom(element.x + element.width / 2, element.y), + center, + element.angle, + ), + ]; + const candidate = sideMidpoints.find( + (midpoint) => + pointDistance(point, midpoint) <= + maxBindingDistance_simple(zoom) + element.strokeWidth / 2 && + !hitElementItself({ + point, + element, + threshold: 0, + elementsMap, + overrideShouldTestInside: true, + }), + ); + + return candidate; +}; + export const projectFixedPointOntoDiagonal = ( arrow: ExcalidrawArrowElement, point: GlobalPoint, - element: ExcalidrawElement, + element: ExcalidrawBindableElement, startOrEnd: "start" | "end", elementsMap: ElementsMap, + zoom: AppState["zoom"], ): GlobalPoint | null => { invariant(arrow.points.length >= 2, "Arrow must have at least two points"); if (arrow.width < 3 && arrow.height < 3) { return null; } + const sideMidPoint = getSnapOutlineMidPoint( + point, + element, + elementsMap, + zoom, + ); + if (sideMidPoint) { + return sideMidPoint; + } + + // Do the projection onto the diagonals (or center lines + // for non-rectangular shapes) const [diagonalOne, diagonalTwo] = getDiagonalsForBindableElement( element, elementsMap, ); - const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( + // To avoid working with stale arrow state, we use the opposite focus point + // of the current endpoint, which will always be unchanged during moving of + // the endpoint. This is only needed when the arrow has only two points. + let a = LinearElementEditor.getPointAtIndexGlobalCoordinates( arrow, startOrEnd === "start" ? 1 : arrow.points.length - 2, elementsMap, ); + if (arrow.points.length === 2) { + const otherBinding = + startOrEnd === "start" ? arrow.endBinding : arrow.startBinding; + const otherBindable = + otherBinding && + (elementsMap.get(otherBinding.elementId) as + | ExcalidrawBindableElement + | undefined); + const otherFocusPoint = + otherBinding && + otherBindable && + getGlobalFixedPointForBindableElement( + normalizeFixedPoint(otherBinding.fixedPoint), + otherBindable, + elementsMap, + ); + if (otherFocusPoint) { + a = otherFocusPoint; + } + } + const b = pointFromVector( vectorScale( vectorFromPoint(point, a), @@ -603,18 +722,22 @@ export const projectFixedPointOntoDiagonal = ( ), a, ); - const intersector = lineSegment(point, b); + const intersector = lineSegment(b, a); const p1 = lineSegmentIntersectionPoints(diagonalOne, intersector); const p2 = lineSegmentIntersectionPoints(diagonalTwo, intersector); const d1 = p1 && pointDistance(a, p1); const d2 = p2 && pointDistance(a, p2); - let p = null; + let projection = null; if (d1 != null && d2 != null) { - p = d1 < d2 ? p1 : p2; + projection = d1 < d2 ? p1 : p2; } else { - p = p1 || p2 || null; + projection = p1 || p2 || null; } - return p && isPointInElement(p, element, elementsMap) ? p : null; + if (projection && isPointInElement(projection, element, elementsMap)) { + return projection; + } + + return null; }; diff --git a/packages/element/src/zindex.ts b/packages/element/src/zindex.ts index 19d9629e0c..14482bb18e 100644 --- a/packages/element/src/zindex.ts +++ b/packages/element/src/zindex.ts @@ -156,12 +156,11 @@ export const moveArrowAboveBindable = ( elements: readonly Ordered[], elementsMap: NonDeletedSceneElementsMap, scene: Scene, + hit?: NonDeletedExcalidrawElement, ): readonly OrderedExcalidrawElement[] => { - const hoveredElement = getHoveredElementForBinding( - point, - elements, - elementsMap, - ); + const hoveredElement = hit + ? hit + : getHoveredElementForBinding(point, elements, elementsMap); if (!hoveredElement) { return elements; diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index 72996bdb1f..4061a16cb6 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -1,9 +1,12 @@ +import { arrayToMap } from "@excalidraw/common"; import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math"; import { Excalidraw } from "@excalidraw/excalidraw"; +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; import "@excalidraw/utils/test-utils"; import { render } from "@excalidraw/excalidraw/tests/test-utils"; +import * as distance from "../src/distance"; import { hitElementItself } from "../src/collision"; describe("check rotated elements can be hit:", () => { @@ -25,8 +28,6 @@ describe("check rotated elements can be hit:", () => { [-4, -302], ] as LocalPoint[], }); - //const p = [120, -211]; - //const p = [0, 13]; const hit = hitElementItself({ point: pointFrom(88, -68), element: window.h.elements[0], @@ -36,3 +37,182 @@ describe("check rotated elements can be hit:", () => { expect(hit).toBe(true); }); }); + +describe("hitElementItself cache", () => { + beforeEach(async () => { + // reset cache + hitElementItself({ + point: pointFrom(50, 50), + element: API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "#ffffff", + }), + threshold: Infinity, + elementsMap: new Map([]), + }); + + localStorage.clear(); + await render(); + }); + + it("reuses cached result when threshold increases", () => { + const element = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "#ffffff", + }); + const elementsMap = arrayToMap([element]); + const point = pointFrom(100.5, 50); + + const distanceSpy = jest.spyOn(distance, "distanceToElement"); + + expect( + hitElementItself({ + point, + element, + threshold: 1, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(1); + + expect( + hitElementItself({ + point, + element, + threshold: 10, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(1); + + distanceSpy.mockRestore(); + }); + + it("does not reuse cache when threshold decreases", () => { + const element = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + }); + const elementsMap = arrayToMap([element]); + const point = pointFrom(105, 50); + + const distanceSpy = jest.spyOn(distance, "distanceToElement"); + + expect( + hitElementItself({ + point, + element, + threshold: 10, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(1); + + expect( + hitElementItself({ + point, + element, + threshold: 6, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(2); + distanceSpy.mockRestore(); + }); + + it("invalidates cache when element version changes", () => { + const element = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "#ffffff", + }); + const elementsMap = arrayToMap([element]); + const point = pointFrom(100.5, 50); + + const distanceSpy = jest.spyOn(distance, "distanceToElement"); + + expect( + hitElementItself({ + point, + element, + threshold: 1, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(1); + + const movedElement = { + ...element, + version: element.version + 1, + versionNonce: element.versionNonce + 1, + }; + + expect( + hitElementItself({ + point, + element: movedElement, + threshold: 1, + elementsMap, + }), + ).toBe(true); + + expect(distanceSpy).toHaveBeenCalledTimes(2); + distanceSpy.mockRestore(); + }); + + it("override does not affect caching", () => { + const element = API.createElement({ + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + backgroundColor: "transparent", + }); + const elementsMap = arrayToMap([element]); + const point = pointFrom(50, 50); + + const distanceSpy = jest.spyOn(distance, "distanceToElement"); + + expect( + hitElementItself({ + point, + element, + threshold: 10, + elementsMap, + }), + ).toBe(false); + + expect(distanceSpy).toHaveBeenCalledTimes(1); + + expect( + hitElementItself({ + point, + element, + threshold: 10, + elementsMap, + overrideShouldTestInside: true, + }), + ).toBe(true); + }); +}); diff --git a/packages/element/tests/embeddable.test.ts b/packages/element/tests/embeddable.test.ts index 7f585e866f..35870a86f2 100644 --- a/packages/element/tests/embeddable.test.ts +++ b/packages/element/tests/embeddable.test.ts @@ -1,4 +1,4 @@ -import { getEmbedLink } from "../src/embeddable"; +import { embeddableURLValidator, getEmbedLink } from "../src/embeddable"; describe("YouTube timestamp parsing", () => { it("should parse YouTube URLs with timestamp in seconds", () => { @@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => { } }); }); + +describe("Google Drive video embedding", () => { + it.each([ + { + url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + { + url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456", + expectedLink: + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview", + }, + ])("should normalize Google Drive link: $url", ({ url, expectedLink }) => { + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe(expectedLink); + } + expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 }); + }); + + it("should preserve resourcekey when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456", + ); + } + }); + + it("should preserve timestamp when available", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9", + ); + } + }); + + it("should preserve resourcekey and timestamp together", () => { + const url = + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9"; + const result = getEmbedLink(url); + + expect(result).toBeTruthy(); + expect(result?.type).toBe("video"); + if (result?.type === "video" || result?.type === "generic") { + expect(result.link).toBe( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9", + ); + } + }); + + it("should validate Google Drive domain by default", () => { + expect( + embeddableURLValidator( + "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view", + undefined, + ), + ).toBe(true); + }); +}); diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 5759c591dd..9485dcb222 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -218,7 +218,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`8`); - expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); + expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); expect(line.points.length).toEqual(3); expect(line.points).toMatchInlineSnapshot(` [ @@ -378,7 +378,7 @@ describe("Test Linear Elements", () => { // drag line from midpoint drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -419,7 +419,7 @@ describe("Test Linear Elements", () => { fireEvent.click(screen.getByTitle("Round")); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `9`, + `10`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`); @@ -480,7 +480,7 @@ describe("Test Linear Elements", () => { drag(startPoint, endPoint); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -548,7 +548,7 @@ describe("Test Linear Elements", () => { ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `14`, + `15`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); @@ -599,7 +599,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -640,7 +640,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -688,7 +688,7 @@ describe("Test Linear Elements", () => { deletePoint(points[2]); expect(line.points.length).toEqual(3); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `17`, + `18`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`10`); @@ -746,7 +746,7 @@ describe("Test Linear Elements", () => { ), ); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `14`, + `15`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`); expect(line.points.length).toEqual(5); @@ -844,7 +844,7 @@ describe("Test Linear Elements", () => { drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta)); expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot( - `11`, + `12`, ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`); @@ -1317,7 +1317,7 @@ describe("Test Linear Elements", () => { const textElement = h.elements[2] as ExcalidrawTextElementWithContainer; expect(arrow.endBinding?.elementId).toBe(rect.id); - expect(arrow.width).toBeCloseTo(399); + expect(arrow.width).toBeCloseTo(404); expect(rect.x).toBe(400); expect(rect.y).toBe(0); expect( @@ -1336,7 +1336,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBeCloseTo(199); + expect(arrow.width).toBeCloseTo(204); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/element/tests/resize.test.tsx b/packages/element/tests/resize.test.tsx index b51d537e37..15e52dfa1b 100644 --- a/packages/element/tests/resize.test.tsx +++ b/packages/element/tests/resize.test.tsx @@ -1350,8 +1350,8 @@ describe("multiple selection", () => { expect(boundArrow.x).toBeCloseTo(380 * scaleX); expect(boundArrow.y).toBeCloseTo(240 * scaleY); - expect(boundArrow.points[1][0]).toBeCloseTo(59.7979); - expect(boundArrow.points[1][1]).toBeCloseTo(-79.7305); + expect(boundArrow.points[1][0]).toBeCloseTo(63.40354208105561); + expect(boundArrow.points[1][1]).toBeCloseTo(-84.53805610807356); expect(arrowLabelPos.x + arrowLabel.width / 2).toBeCloseTo( boundArrow.x + boundArrow.points[1][0] / 2, diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index 88e085f1de..6c75603478 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -58,6 +58,7 @@ const distributeSelectedElements = ( app.scene.getNonDeletedElementsMap(), distribution, appState, + app.scene, ); const updatedElementsMap = arrayToMap(updatedElements); diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 1dd6884259..9e529621a7 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -98,19 +98,21 @@ export const actionFinalize = register({ map.set(index, { point: LinearElementEditor.pointFromAbsoluteCoords( element, - pointFrom(sceneCoords.x, sceneCoords.y), + pointFrom( + sceneCoords.x - linearElementEditor.pointerOffset.x, + sceneCoords.y - linearElementEditor.pointerOffset.y, + ), elementsMap, ), }); return map; }, new Map()) ?? new Map(); - bindOrUnbindBindingElement( element, draggedPoints, - sceneCoords.x, - sceneCoords.y, + sceneCoords.x - linearElementEditor.pointerOffset.x, + sceneCoords.y - linearElementEditor.pointerOffset.y, scene, appState, { @@ -173,6 +175,7 @@ export const actionFinalize = register({ ...linearElementEditor.initialState, lastClickedPoint: -1, }, + pointerOffset: { x: 0, y: 0 }, }, selectionElement: null, suggestedBinding: null, diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 75b4c4f019..9a693fd4a9 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1555,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => { value: null, text: t("labels.arrowhead_none"), keyBinding: "q", - icon: ArrowheadNoneIcon, + icon: , }, { value: "arrow", @@ -1683,7 +1683,8 @@ export const actionChangeArrowhead = register<{ ? element.startArrowhead : appState.currentItemStartArrowhead, true, - appState.currentItemStartArrowhead, + (hasSelection) => + hasSelection ? null : appState.currentItemStartArrowhead, )} onChange={(value) => updateData({ position: "start", type: value })} numberOfOptionsToAlwaysShow={4} @@ -1700,7 +1701,8 @@ export const actionChangeArrowhead = register<{ ? element.endArrowhead : appState.currentItemEndArrowhead, true, - appState.currentItemEndArrowhead, + (hasSelection) => + hasSelection ? null : appState.currentItemEndArrowhead, )} onChange={(value) => updateData({ position: "end", type: value })} numberOfOptionsToAlwaysShow={4} diff --git a/packages/excalidraw/actions/shortcuts.ts b/packages/excalidraw/actions/shortcuts.ts index ca593c3402..ca4119d251 100644 --- a/packages/excalidraw/actions/shortcuts.ts +++ b/packages/excalidraw/actions/shortcuts.ts @@ -55,7 +55,8 @@ export type ShortcutName = | "saveScene" | "imageExport" | "commandPalette" - | "searchMenu"; + | "searchMenu" + | "toolLock"; const shortcutMap: Record = { toggleTheme: [getShortcutKey("Shift+Alt+D")], @@ -117,6 +118,7 @@ const shortcutMap: Record = { toggleShortcuts: [getShortcutKey("?")], searchMenu: [getShortcutKey("CtrlOrCmd+F")], wrapSelectionInFrame: [], + toolLock: [getShortcutKey("Q")], }; export const getShortcutFromShortcutName = (name: ShortcutName, idx = 0) => { diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 82586be897..b8b40f7f32 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -204,8 +204,13 @@ export const copyToClipboard = async ( /** supply if available to make the operation more certain to succeed */ clipboardEvent?: ClipboardEvent | null, ) => { + const json = serializeAsClipboardJSON({ elements, files }); + await copyTextToSystemClipboard( - serializeAsClipboardJSON({ elements, files }), + { + [MIME_TYPES.excalidrawClipboard]: json, + [MIME_TYPES.text]: json, + }, clipboardEvent, ); }; @@ -401,7 +406,7 @@ export type ParsedDataTransferFile = Extract< { kind: "file" } >; -type ParsedDataTranferList = ParsedDataTransferItem[] & { +export type ParsedDataTranferList = ParsedDataTransferItem[] & { /** * Only allows filtering by known `string` data types, since `file` * types can have multiple items of the same type (e.g. multiple image files) @@ -452,6 +457,29 @@ const getDataTransferFiles = function ( ); }; +/** @returns list of MIME types, synchronously */ +export const parseDataTransferEventMimeTypes = ( + event: ClipboardEvent | DragEvent | React.DragEvent, +): Set => { + let items: DataTransferItemList | undefined = undefined; + + if (isClipboardEvent(event)) { + items = event.clipboardData?.items; + } else { + items = event.dataTransfer?.items; + } + + const types: Set = new Set(); + + for (const item of Array.from(items || [])) { + if (!types.has(item.type)) { + types.add(item.type); + } + } + + return types; +}; + export const parseDataTransferEvent = async ( event: ClipboardEvent | DragEvent | React.DragEvent, ): Promise => { @@ -460,8 +488,7 @@ export const parseDataTransferEvent = async ( if (isClipboardEvent(event)) { items = event.clipboardData?.items; } else { - const dragEvent = event; - items = dragEvent.dataTransfer?.items; + items = event.dataTransfer?.items; } const dataItems = ( @@ -567,7 +594,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { // ClipboardItem constructor, but throws on an unrelated MIME type error. // So we need to await this and fallback to awaiting the blob if applicable. await navigator.clipboard.write([ - new window.ClipboardItem({ + new ClipboardItem({ [MIME_TYPES.png]: blob, }), ]); @@ -576,7 +603,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { // with resolution value instead if (isPromiseLike(blob)) { await navigator.clipboard.write([ - new window.ClipboardItem({ + new ClipboardItem({ [MIME_TYPES.png]: await blob, }), ]); @@ -586,28 +613,27 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { } }; -export const copyTextToSystemClipboard = async ( - text: string | null, +export const copyTextToSystemClipboard = async < + MimeType extends ValueOf, +>( + text: string | { [K in MimeType]: string } | null, clipboardEvent?: ClipboardEvent | null, ) => { - // (1) first try using Async Clipboard API - if (probablySupportsClipboardWriteText) { - try { - // NOTE: doesn't work on FF on non-HTTPS domains, or when document - // not focused - await navigator.clipboard.writeText(text || ""); - return; - } catch (error: any) { - console.error(error); - } - } + text = text || ""; - // (2) if fails and we have access to ClipboardEvent, use plain old setData() + const entries = Object.entries( + typeof text === "string" ? { [MIME_TYPES.text]: text } : text, + ); + + // (1) if we have clipboardEvent, try using it first as it's the most + // versatile try { if (clipboardEvent) { - clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || ""); - if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) { - throw new Error("Failed to setData on clipboardEvent"); + for (const [mimeType, value] of entries) { + clipboardEvent.clipboardData?.setData(mimeType, value); + if (clipboardEvent.clipboardData?.getData(mimeType) !== value) { + throw new Error("Failed to setData on clipboardEvent"); + } } return; } @@ -615,8 +641,26 @@ export const copyTextToSystemClipboard = async ( console.error(error); } - // (3) if that fails, use document.execCommand - if (!copyTextViaExecCommand(text)) { + const plainTextEntry = entries.find( + ([mimeType]) => mimeType === MIME_TYPES.text, + ); + + // (2) if we don't have access to clipboardEvent, or that fails, + // at least try setting text/plain via navigator.clipboard.writeText + // (navigator.clipboard.write doesn't work with non-standard mime types) + if (probablySupportsClipboardWriteText && plainTextEntry) { + try { + // NOTE: doesn't work on FF on non-HTTPS domains, or when document + // not focused + await navigator.clipboard.writeText(plainTextEntry[1]); + return; + } catch (error: any) { + console.error(error); + } + } + + // (3) if previous fails, use document.execCommand + if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) { throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index 147abf3b09..5573c1301e 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { useRef, useState } from "react"; -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { CLASSES, @@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({ return ( <> {getToolbarTools(app).map( - ({ value, icon, key, numericKey, fillable }) => { + ({ value, icon, key, numericKey, fillable, toolbar }) => { if ( + toolbar === false || UIOptions.tools?.[ value as Extract< typeof value, @@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + const keybindingLabel = + value === "hand" ? undefined : numericKey || letter; + // when in compact styles panel mode (tablet) // use a ToolPopover for selection/lasso toggle as well if ( @@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({ checked={activeTool.type === value} name="editor-current-shape" title={`${capitalizeString(label)} — ${shortcut}`} - keyBindingLabel={numericKey || letter} + keyBindingLabel={keybindingLabel} aria-label={capitalizeString(label)} aria-keyshortcuts={shortcut} data-testid={`toolbar-${value}`} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 128432893c..f91aca4110 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -12,7 +12,6 @@ import { pointDistance, vector, pointRotateRads, - vectorScale, vectorFromPoint, vectorSubtract, vectorDot, @@ -110,6 +109,7 @@ import { loadDesktopUIModePreference, setDesktopUIMode, isSelectionLikeTool, + oneOf, } from "@excalidraw/common"; import { @@ -251,6 +251,13 @@ import { maxBindingDistance_simple, convertToExcalidrawElements, type ExcalidrawElementSkeleton, + getSnapOutlineMidPoint, + handleFocusPointDrag, + handleFocusPointHover, + handleFocusPointPointerDown, + handleFocusPointPointerUp, + maybeHandleArrowPointlikeDrag, + getUncroppedWidthAndHeight, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -437,10 +444,7 @@ import { searchItemInFocusAtom } from "./SearchMenu"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { - isPointHittingLink, - isPointHittingLinkIcon, -} from "./hyperlink/helpers"; +import { isPointHittingLink } from "./hyperlink/helpers"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { Toast } from "./Toast"; @@ -644,7 +648,10 @@ class App extends React.Component { lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null; + /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; + /** previous frame pointer coords */ + previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; animationFrameHandler = new AnimationFrameHandler(); @@ -1202,12 +1209,112 @@ class App extends React.Component { return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { + private handleIframeLikeElementHover = ({ + hitElement, + scenePointer, + moveEvent, + }: { + hitElement: NonDeleted | null; + scenePointer: { x: number; y: number }; + moveEvent: React.PointerEvent; + }): boolean => { if ( - this.state.activeEmbeddable?.element === element && + hitElement && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + moveEvent, + scenePointer.x, + scenePointer.y, + )) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + this.setState({ + activeEmbeddable: { element: hitElement, state: "hover" }, + }); + return true; + } else if (this.state.activeEmbeddable?.state === "hover") { + this.setState({ activeEmbeddable: null }); + } + return false; + }; + + /** @returns true if iframe-like element click handled */ + private handleIframeLikeCenterClick(): boolean { + if ( + !this.lastPointerDownEvent || + !this.lastPointerUpEvent || + // middle-click or something other than primary + this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN || + // panning + isHoldingSpace || + // wrong tool + !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"]) + ) { + return false; + } + + const viewportClickStart_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerDownEvent.clientX, + clientY: this.lastPointerDownEvent.clientY, + }, + this.state, + ), + ); + const viewportClickEnd_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerUpEvent.clientX, + clientY: this.lastPointerUpEvent.clientY, + }, + this.state, + ), + ); + + const draggedDistance = pointDistance( + viewportClickStart_scenePoint, + viewportClickEnd_scenePoint, + ); + + if (draggedDistance > DRAGGING_THRESHOLD) { + return false; + } + + const hitElement = this.getElementAtPosition( + viewportClickStart_scenePoint[0], + viewportClickStart_scenePoint[1], + ); + + const shouldActivate = + hitElement && + this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <= + 300 && + gesture.pointers.size < 2 && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + this.lastPointerUpEvent, + viewportClickEnd_scenePoint[0], + viewportClickEnd_scenePoint[1], + )); + + if (!shouldActivate) { + return false; + } + + const iframeLikeElement = hitElement; + + if ( + this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.state === "active" ) { - return; + return true; } // The delay serves two purposes @@ -1218,31 +1325,34 @@ class App extends React.Component { // in fullscreen mode setTimeout(() => { this.setState({ - activeEmbeddable: { element, state: "active" }, - selectedElementIds: { [element.id]: true }, + activeEmbeddable: { element: iframeLikeElement, state: "active" }, + selectedElementIds: { [iframeLikeElement.id]: true }, newElement: null, selectionElement: null, }); }, 100); - if (isIframeElement(element)) { - return; + if (isIframeElement(iframeLikeElement)) { + return true; } - const iframe = this.getHTMLIFrameElement(element); + const iframe = this.getHTMLIFrameElement(iframeLikeElement); if (!iframe?.contentWindow) { - return; + return true; } if (iframe.src.includes("youtube")) { - const state = YOUTUBE_VIDEO_STATES.get(element.id); + const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); if (!state) { - YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); + YOUTUBE_VIDEO_STATES.set( + iframeLikeElement.id, + YOUTUBE_STATES.UNSTARTED, + ); iframe.contentWindow.postMessage( JSON.stringify({ event: "listening", - id: element.id, + id: iframeLikeElement.id, }), "*", ); @@ -1279,6 +1389,8 @@ class App extends React.Component { "*", ); } + + return true; } private isIframeLikeElementCenter( @@ -4737,14 +4849,86 @@ class App extends React.Component { } // Handle Alt key for bind mode - if (event.key === KEYS.ALT && getFeatureFlag("COMPLEX_BINDINGS")) { - this.handleSkipBindMode(); + if (event.key === KEYS.ALT) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleSkipBindMode(); + } else { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } } if (this.actionManager.handleKeyDown(event)) { return; } + // view mode hardcoded from upstream -> disable tool switching for now + const shouldPreventToolSwitching = this.props.viewModeEnabled === true; + + if ( + !shouldPreventToolSwitching && + this.state.viewModeEnabled && + event.key === KEYS.ESCAPE + ) { + this.setActiveTool({ type: "hand" }); + return; + } + + if ( + !shouldPreventToolSwitching && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !this.state.newElement && + !this.state.selectionElement && + !this.state.selectedElementsAreBeingDragged + ) { + const shape = findShapeByKey(event.key, this); + + if (this.state.viewModeEnabled && !oneOf(shape, ["laser", "hand"])) { + return; + } + + if (shape) { + if (this.state.activeTool.type !== shape) { + trackEvent( + "toolbar", + shape, + `keyboard (${ + this.editorInterface.formFactor === "phone" + ? "mobile" + : "desktop" + })`, + ); + } + if (shape === "arrow" && this.state.activeTool.type === "arrow") { + this.setState((prevState) => ({ + currentItemArrowType: + prevState.currentItemArrowType === ARROW_TYPE.sharp + ? ARROW_TYPE.round + : prevState.currentItemArrowType === ARROW_TYPE.round + ? ARROW_TYPE.elbow + : ARROW_TYPE.sharp, + })); + } + + if (shape === "lasso" && this.state.activeTool.type === "laser") { + this.setActiveTool({ + type: this.state.preferredSelectionTool.type, + }); + } else { + this.setActiveTool({ type: shape }); + } + + event.stopPropagation(); + + return; + } else if (event.key === KEYS.Q) { + this.toggleLock("keyboard"); + event.stopPropagation(); + return; + } + } + if (this.state.viewModeEnabled) { return; } @@ -4754,7 +4938,11 @@ class App extends React.Component { this.resetDelayedBindMode(); } - this.setState({ isBindingEnabled: false }); + flushSync(() => { + this.setState({ isBindingEnabled: false }); + }); + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { @@ -4874,44 +5062,8 @@ class App extends React.Component { }); } } - } else if ( - !event.ctrlKey && - !event.altKey && - !event.metaKey && - !this.state.newElement && - !this.state.selectionElement && - !this.state.selectedElementsAreBeingDragged - ) { - const shape = findShapeByKey(event.key, this); - if (shape) { - if (this.state.activeTool.type !== shape) { - trackEvent( - "toolbar", - shape, - `keyboard (${ - this.editorInterface.formFactor === "phone" - ? "mobile" - : "desktop" - })`, - ); - } - if (shape === "arrow" && this.state.activeTool.type === "arrow") { - this.setState((prevState) => ({ - currentItemArrowType: - prevState.currentItemArrowType === ARROW_TYPE.sharp - ? ARROW_TYPE.round - : prevState.currentItemArrowType === ARROW_TYPE.round - ? ARROW_TYPE.elbow - : ARROW_TYPE.sharp, - })); - } - this.setActiveTool({ type: shape }); - event.stopPropagation(); - } else if (event.key === KEYS.Q) { - this.toggleLock("keyboard"); - event.stopPropagation(); - } } + if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { isHoldingSpace = true; setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -4975,15 +5127,6 @@ class App extends React.Component { } } - if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { - if (this.state.activeTool.type === "laser") { - this.setActiveTool({ type: this.state.preferredSelectionTool.type }); - } else { - this.setActiveTool({ type: "laser" }); - } - return; - } - if ( event[KEYS.CTRL_OR_CMD] && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) @@ -5010,7 +5153,8 @@ class App extends React.Component { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { if (event.key === KEYS.SPACE) { if ( - this.state.viewModeEnabled || + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser") || this.state.openDialog?.name === "elementLinkSelector" ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -5027,6 +5171,11 @@ class App extends React.Component { } isHoldingSpace = false; } + + if (event.key === KEYS.ALT) { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } + if ( (event.key === KEYS.ALT && this.state.bindMode === "skip") || (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) @@ -5037,7 +5186,7 @@ class App extends React.Component { }); // Restart the timer if we're creating/editing a linear element and hovering over an element - if (this.lastPointerMoveEvent) { + if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) { const scenePointer = viewportCoordsToSceneCoords( { clientX: this.lastPointerMoveEvent.clientX, @@ -5058,14 +5207,18 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), ); - if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + if (isBindingElement(element)) { this.handleDelayedBindModeChange(element, hoveredElement); } } } } if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { - this.setState({ isBindingEnabled: true }); + flushSync(() => { + this.setState({ isBindingEnabled: true }); + }); + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { bindOrUnbindBindingElements( @@ -6115,7 +6268,9 @@ class App extends React.Component { } }; - private redirectToLink = (event: React.PointerEvent) => { + private handleElementLinkClick = ( + event: React.PointerEvent, + ) => { const draggedDistance = pointDistance( pointFrom( this.lastPointerDownEvent!.clientX, @@ -6375,15 +6530,28 @@ class App extends React.Component { // and point const { newElement } = this.state; if (!newElement && isBindingEnabled(this.state)) { + const globalPoint = pointFrom( + scenePointerX, + scenePointerY, + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const hoveredElement = getHoveredElementForBinding( - pointFrom(scenePointerX, scenePointerY), + globalPoint, this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - () => maxBindingDistance_simple(this.state.zoom), + elementsMap, + maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.setState({ - suggestedBinding: hoveredElement, + suggestedBinding: { + element: hoveredElement, + midPoint: getSnapOutlineMidPoint( + globalPoint, + hoveredElement, + elementsMap, + this.state.zoom, + ), + }, }); } else if (this.state.suggestedBinding) { this.setState({ @@ -6410,7 +6578,7 @@ class App extends React.Component { pointFrom(scenePointerX, scenePointerY), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - () => maxBindingDistance_simple(this.state.zoom), + maxBindingDistance_simple(this.state.zoom), ); if (hoveredElement) { this.actionManager.executeAction(actionFinalize, "ui", { @@ -6564,26 +6732,33 @@ class App extends React.Component { pointFrom(scenePointerX, scenePointerY), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - () => maxBindingDistance_simple(this.state.zoom), + maxBindingDistance_simple(this.state.zoom), ); - if ( - hit && - !isPointInElement( - pointFrom(scenePointerX, scenePointerY), - hit, - this.scene.getNonDeletedElementsMap(), - ) - ) { + const scenePointer = pointFrom(scenePointerX, scenePointerY); + const elementsMap = this.scene.getNonDeletedElementsMap(); + if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { this.setState({ - suggestedBinding: hit, + suggestedBinding: { + element: hit, + midPoint: getSnapOutlineMidPoint( + scenePointer, + hit, + elementsMap, + this.state.zoom, + ), + }, }); } } - const hasDeselectedButton = Boolean(event.buttons); + const isPressingAnyButton = Boolean(event.buttons); + const isLaserTool = this.state.activeTool.type === "laser"; if ( - hasDeselectedButton || - (this.state.activeTool.type !== "selection" && + isPressingAnyButton || + // checking against laser so that if you mouseover with a laser tool + // over a link/embeddable, we change the cursor + (!isLaserTool && + this.state.activeTool.type !== "selection" && this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "text" && this.state.activeTool.type !== "eraser") @@ -6668,6 +6843,10 @@ class App extends React.Component { } } + if (isEraserActive(this.state)) { + return; + } + const hitElementMightBeLocked = this.getElementAtPosition( scenePointerX, scenePointerY, @@ -6684,18 +6863,25 @@ class App extends React.Component { hitElement = hitElementMightBeLocked; } - this.hitLinkElement = this.getElementLinkAtPosition( - scenePointer, - hitElementMightBeLocked, - ); - if (isEraserActive(this.state)) { - return; + if ( + !this.handleIframeLikeElementHover({ + hitElement, + scenePointer, + moveEvent: event, + }) + ) { + this.hitLinkElement = this.getElementLinkAtPosition( + scenePointer, + hitElementMightBeLocked, + ); } + if ( this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + showHyperlinkTooltip( this.hitLinkElement, this.state, @@ -6703,6 +6889,9 @@ class App extends React.Component { ); } else { hideHyperlinkToolip(); + if (isLaserTool) { + return; + } if ( hitElement && (hitElement.link || isEmbeddableElement(hitElement)) && @@ -6735,20 +6924,6 @@ class App extends React.Component { !hitElement?.locked ) { if ( - hitElement && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - event, - scenePointerX, - scenePointerY, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - this.setState({ - activeEmbeddable: { element: hitElement, state: "hover" }, - }); - } else if ( !hitElement || // Elbow arrows can only be moved when unconnected !isElbowArrow(hitElement) || @@ -6760,9 +6935,6 @@ class App extends React.Component { ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } - if (this.state.activeEmbeddable?.state === "hover") { - this.setState({ activeEmbeddable: null }); - } } } } else { @@ -6923,6 +7095,37 @@ class App extends React.Component { }, }); } + + // Check for focus point hover + let hoveredFocusPointBinding: "start" | "end" | null = null; + const arrow = element as any; + if (arrow.startBinding || arrow.endBinding) { + hoveredFocusPointBinding = handleFocusPointHover( + element as ExcalidrawArrowElement, + scenePointerX, + scenePointerY, + this.scene, + this.state, + ); + } + + if ( + this.state.selectedLinearElement.hoveredFocusPointBinding !== + hoveredFocusPointBinding + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + hoveredFocusPointBinding, + }, + }); + } + + // Set cursor to pointer when hovering over a focus point + if (hoveredFocusPointBinding) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + } } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } @@ -7385,26 +7588,9 @@ class App extends React.Component { x: scenePointerX, y: scenePointerY, }; - const clicklength = - event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); - if (this.editorInterface.formFactor === "phone" && clicklength < 300) { - const hitElement = this.getElementAtPosition( - scenePointer.x, - scenePointer.y, - ); - if ( - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - event, - scenePointer.x, - scenePointer.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - return; - } + if (this.handleIframeLikeCenterClick()) { + return; } if (this.editorInterface.isTouchScreen) { @@ -7425,20 +7611,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - if ( - clicklength < 300 && - isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon( - this.hitLinkElement, - this.scene.getNonDeletedElementsMap(), - this.state, - pointFrom(scenePointer.x, scenePointer.y), - ) - ) { - this.handleEmbeddableCenterClick(this.hitLinkElement); - } else { - this.redirectToLink(event); - } + this.handleElementLinkClick(event); } else if (this.state.viewModeEnabled) { this.setState({ activeEmbeddable: null, @@ -7498,7 +7671,8 @@ class App extends React.Component { (event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || isHandToolActive(this.state) || - this.state.viewModeEnabled) + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser")) ) ) { return false; @@ -7576,7 +7750,10 @@ class App extends React.Component { lastPointerUp = null; isPanning = false; if (!isHoldingSpace) { - if (this.state.viewModeEnabled) { + if ( + this.state.viewModeEnabled && + this.state.activeTool.type !== "laser" + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else { setCursorForShape(this.interactiveCanvas, this.state); @@ -7847,6 +8024,37 @@ class App extends React.Component { if (ret.didAddPoint) { return true; } + + // Also check at current pointer position if focus point is being hovered + // (in case we're clicking directly without a prior move event) + const elementsMap = this.scene.getNonDeletedElementsMap(); + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ) as any; + + if (arrow && isBindingElement(arrow)) { + const { hitFocusPoint, pointerOffset } = + handleFocusPointPointerDown( + arrow, + pointerDownState, + elementsMap, + this.state, + ); + + // If focus point is hit, update state and prevent element selection + if (hitFocusPoint) { + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + hoveredFocusPointBinding: hitFocusPoint, + draggedFocusPointBinding: hitFocusPoint, + pointerOffset, + }, + }); + return false; + } + } } const allHitElements = this.getElementsAtPosition( @@ -8661,6 +8869,7 @@ class App extends React.Component { selectedPointsIndices: [endIdx], initialState: { ...linearElementEditor.initialState, + arrowStartIsInside: event.altKey, lastClickedPoint: endIdx, origin: pointFrom( pointerDownState.origin.x, @@ -8679,7 +8888,18 @@ class App extends React.Component { bindMode: "orbit", newElement: element, startBoundElement: boundElement, - suggestedBinding: boundElement || null, + suggestedBinding: + boundElement && isBindingElement(element) + ? { + element: boundElement, + midPoint: getSnapOutlineMidPoint( + point, + boundElement, + elementsMap, + this.state.zoom, + ), + } + : null, selectedElementIds: nextSelectedElementIds, selectedLinearElement: linearElementEditor, }; @@ -8943,8 +9163,8 @@ class App extends React.Component { } const lastPointerCoords = - this.lastPointerMoveCoords ?? pointerDownState.origin; - this.lastPointerMoveCoords = pointerCoords; + this.previousPointerMoveCoords ?? pointerDownState.origin; + this.previousPointerMoveCoords = pointerCoords; // We need to initialize dragOffsetXY only after we've updated // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove @@ -8998,6 +9218,31 @@ class App extends React.Component { if (this.state.selectedLinearElement) { const linearElementEditor = this.state.selectedLinearElement; + // Handle focus point dragging if needed + if (linearElementEditor.draggedFocusPointBinding) { + handleFocusPointDrag( + linearElementEditor, + elementsMap, + pointerCoords, + this.scene, + this.state, + this.getEffectiveGridSize(), + event.altKey, + ); + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + isDragging: false, + selectedPointsIndices: [], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: -1, + }, + }, + }); + return; + } + if ( LinearElementEditor.shouldAddMidpoint( this.state.selectedLinearElement, @@ -9236,14 +9481,21 @@ class App extends React.Component { this.imageCache.get(croppingElement.fileId)?.image; if (image && !(image instanceof Promise)) { - const instantDragOffset = vectorScale( - vector( - pointerCoords.x - lastPointerCoords.x, - pointerCoords.y - lastPointerCoords.y, - ), - Math.max(this.state.zoom.value, 2), + const uncroppedSize = + getUncroppedWidthAndHeight(croppingElement); + const instantDragOffset = vector( + pointerCoords.x - lastPointerCoords.x, + pointerCoords.y - lastPointerCoords.y, ); + // to reduce cursor:image drift, we need to take into account + // the canvas image element scaling so we can accurately + // track the pixels on movement + instantDragOffset[0] *= + image.naturalWidth / uncroppedSize.width; + instantDragOffset[1] *= + image.naturalHeight / uncroppedSize.height; + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( croppingElement, elementsMap, @@ -9286,13 +9538,13 @@ class App extends React.Component { const nextCrop = { ...crop, x: clamp( - crop.x + + crop.x - offsetVector[0] * Math.sign(croppingElement.scale[0]), 0, image.naturalWidth - crop.width, ), y: clamp( - crop.y + + crop.y - offsetVector[1] * Math.sign(croppingElement.scale[1]), 0, image.naturalHeight - crop.height, @@ -9785,6 +10037,7 @@ class App extends React.Component { // just in case, tool changes mid drag, always clean up this.lassoTrail.endPath(); + this.previousPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -9868,12 +10121,14 @@ class App extends React.Component { // and sets binding element if ( this.state.selectedLinearElement?.isEditing && - !this.state.newElement + !this.state.newElement && + this.state.selectedLinearElement.draggedFocusPointBinding === null ) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== - this.state.selectedLinearElement.elementId + this.state.selectedLinearElement.elementId && + this.state.selectedLinearElement.draggedFocusPointBinding === null ) { this.actionManager.executeAction(actionFinalize); } else { @@ -9909,7 +10164,18 @@ class App extends React.Component { } } - if ( + if (this.state.selectedLinearElement.draggedFocusPointBinding) { + handleFocusPointPointerUp( + this.state.selectedLinearElement, + this.scene, + ); + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + draggedFocusPointBinding: null, + }, + }); + } else if ( pointerDownState.hit?.element?.id !== this.state.selectedLinearElement.elementId ) { @@ -9919,6 +10185,12 @@ class App extends React.Component { this.setState({ selectedLinearElement: null }); } } else if (this.state.selectedLinearElement.isDragging) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + }, + }); this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, @@ -10693,25 +10965,6 @@ class App extends React.Component { suggestedBinding: null, }); } - - if ( - hitElement && - this.lastPointerUpEvent && - this.lastPointerDownEvent && - this.lastPointerUpEvent.timeStamp - - this.lastPointerDownEvent.timeStamp < - 300 && - gesture.pointers.size <= 1 && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - this.lastPointerUpEvent, - pointerDownState.origin.x, - pointerDownState.origin.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - } }); } diff --git a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx index f0767b5c93..8514c5353b 100644 --- a/packages/excalidraw/components/ColorPicker/ColorPicker.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import { useRef, useEffect } from "react"; diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 3eed838ce8..bb01d66b1e 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -15,7 +15,7 @@ export type CommandPaletteItem = { category: string; order?: number; predicate?: boolean | Action["predicate"]; - shortcut?: string; + shortcut?: string | null; /** if false, command will not show while in view mode */ viewMode?: boolean; perform: (data: { diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index 431a46a63b..3257a81217 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -53,6 +53,7 @@ &.ExcButton--status-loading, &.ExcButton--status-success { pointer-events: none; + background-color: var(--color-success); .ExcButton__contents { visibility: hidden; diff --git a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx new file mode 100644 index 0000000000..ab92464fd5 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -0,0 +1,31 @@ +import { KEYS } from "@excalidraw/common"; + +import { Excalidraw } from "../.."; +import { Keyboard } from "../../tests/helpers/ui"; +import { act, render } from "../../tests/test-utils"; + +describe("FontPicker", () => { + it("should be able to open font picker", async () => { + (global as any).ResizeObserver = + (global as any).ResizeObserver || + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + + const { queryByTestId } = await render( + , + ); + + Keyboard.keyPress(KEYS.T); + + const fontPickerTrigger = queryByTestId("font-family-show-fonts"); + + expect(fontPickerTrigger).not.toBeNull(); + + act(() => { + fontPickerTrigger!.click(); + }); + }); +}); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index c52286a173..4325147e17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; diff --git a/packages/excalidraw/components/FontPicker/FontPickerList.tsx b/packages/excalidraw/components/FontPicker/FontPickerList.tsx index c91de0d681..abcacfcd9e 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerList.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerList.tsx @@ -30,10 +30,12 @@ import { PropertiesPopover } from "../PropertiesPopover"; import { QuickSearch } from "../QuickSearch"; import { ScrollableList } from "../ScrollableList"; import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup"; -import DropdownMenuItem, { +import { DropDownMenuItemBadgeType, DropDownMenuItemBadge, } from "../dropdownMenu/DropdownMenuItem"; +import MenuItemContent from "../dropdownMenu/DropdownMenuItemContent"; +import { getDropdownMenuItemClassName } from "../dropdownMenu/common"; import { FontFamilyCodeIcon, FontFamilyHeadingIcon, @@ -267,45 +269,74 @@ export const FontPickerList = React.memo( [filteredFonts, sceneFamilies], ); - const renderFont = (font: FontDescriptor, index: number) => ( - { - wrappedOnSelect(Number(e.currentTarget.value)); - }} - onMouseMove={() => { - if (hoveredFont?.value !== font.value) { - onHover(font.value); - } - }} - badge={ - font.badge && ( - - {font.badge.placeholder} - - ) + const FontPickerListItem = ({ + font, + order, + }: { + font: FontDescriptor; + order: number; + }) => { + const ref = useRef(null); + const isHovered = font.value === hoveredFont?.value; + const isSelected = font.value === selectedFontFamily; + + useEffect(() => { + if (!isHovered) { + return; } - > - {font.text} - - ); + if (order === 0) { + // scroll into the first item differently, so it's visible what is above (i.e. group title) + ref.current?.scrollIntoView?.({ block: "end" }); + } else { + ref.current?.scrollIntoView?.({ block: "nearest" }); + } + }, [isHovered, order]); + + return ( + + ); + }; const groups = []; if (sceneFilteredFonts.length) { groups.push( - {sceneFilteredFonts.map(renderFont)} + {sceneFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -313,9 +344,13 @@ export const FontPickerList = React.memo( if (availableFilteredFonts.length) { groups.push( - {availableFilteredFonts.map((font, index) => - renderFont(font, index + sceneFilteredFonts.length), - )} + {availableFilteredFonts.map((font, index) => ( + + ))} , ); } diff --git a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx index ed88eab3b5..dcd3bb3c5f 100644 --- a/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx +++ b/packages/excalidraw/components/FontPicker/FontPickerTrigger.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common"; diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 0bb8ce769b..b7007ee31f 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useEffect } from "react"; diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 2f968318de..ba83307c2b 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -20,7 +20,6 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { actionToggleStats } from "../actions"; import { trackEvent } from "../analytics"; -import { isHandToolActive } from "../appState"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { UIAppStateContext } from "../context/ui-appState"; import { useAtom, useAtomValue } from "../editor-jotai"; @@ -55,7 +54,6 @@ import ElementLinkDialog from "./ElementLinkDialog"; import { ErrorDialog } from "./ErrorDialog"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { FixedSideContainer } from "./FixedSideContainer"; -import { HandButton } from "./HandButton"; import { HelpDialog } from "./HelpDialog"; import { HintViewer } from "./HintViewer"; import { ImageExportDialog } from "./ImageExportDialog"; @@ -359,13 +357,6 @@ const LayerUI = ({
- onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> - + setIsOtherShapesMenuOpen(false)} onSelect={() => setIsOtherShapesMenuOpen(false)} className="App-toolbar__extra-tools-dropdown" + align="start" > {!showTextToolOutside && ( { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("186")); + UI.updateInput(inputX, String("184")); expect(linear.startBinding).not.toBe(null); }); diff --git a/packages/excalidraw/components/TTDDialog/Chat/Chat.scss b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss index 942e9646af..d205d19a5a 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/Chat.scss +++ b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss @@ -48,14 +48,14 @@ $verticalBreakpoint: 861px; } } - &__empty-state { + &__welcome-screen { display: flex; align-items: center; justify-content: center; height: 100%; min-height: 200px; - &-content { + &__welcome-message { text-align: center; h3 { diff --git a/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx index ef20374c7c..87c1132500 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx +++ b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx @@ -52,11 +52,7 @@ export const ChatHistoryMenu = ({ > {historyIcon} - + <> {savedChats.map((chat) => ( void; generatedResponse?: string | null; - placeholder: { - title: string; - description: string; - hint: string; - }; onAbort?: () => void; onMermaidTabClick?: (message: TChat.ChatMessage) => void; onAiRepairClick?: (message: TChat.ChatMessage) => void; onDeleteMessage?: (messageId: string) => void; onInsertMessage?: (message: TChat.ChatMessage) => void; onRetry?: (message: TChat.ChatMessage) => void; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; }) => { const messagesEndRef = useRef(null); @@ -113,12 +111,12 @@ export const ChatInterface = ({
{messages.length === 0 ? ( -
-
-

{placeholder.title}

-

{placeholder.description}

-

{placeholder.hint}

-
+
+ {renderWelcomeScreen ? ( + renderWelcomeScreen({ rateLimits: rateLimits ?? null }) + ) : ( + + )}
) : ( messages.map((message, index) => ( diff --git a/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx b/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx index a9e66df6e4..25aa30df88 100644 --- a/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx +++ b/packages/excalidraw/components/TTDDialog/Chat/TTDChatPanel.tsx @@ -40,6 +40,7 @@ export const TTDChatPanel = ({ onInsertMessage, onRetry, onViewAsMermaid, + renderWelcomeScreen, renderWarning, }: { chatId: string; @@ -68,6 +69,7 @@ export const TTDChatPanel = ({ onViewAsMermaid: () => void; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; }) => { const [rateLimits] = useAtom(rateLimitsAtom); @@ -151,11 +153,7 @@ export const TTDChatPanel = ({ onInsertMessage={onInsertMessage} onRetry={onRetry} rateLimits={rateLimits} - placeholder={{ - title: t("chat.placeholder.title"), - description: t("chat.placeholder.description"), - hint: t("chat.placeholder.hint"), - }} + renderWelcomeScreen={renderWelcomeScreen} renderWarning={renderWarning} /> diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx index a77167613e..a16c0cc385 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx @@ -15,6 +15,8 @@ import { TTDDialogTab } from "./TTDDialogTab"; import "./TTDDialog.scss"; +import { TTDWelcomeMessage } from "./TTDWelcomeMessage"; + import type { MermaidToExcalidrawLibProps, TTDPersistenceAdapter, @@ -25,6 +27,7 @@ export const TTDDialog = ( props: | { onTextSubmit: TTTDDialog.onTextSubmit; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; persistenceAdapter: TTDPersistenceAdapter; } @@ -39,6 +42,8 @@ export const TTDDialog = ( return ; }; +TTDDialog.WelcomeMessage = TTDWelcomeMessage; + /** * Text to diagram (TTD) dialog */ @@ -54,6 +59,7 @@ const TTDDialogBase = withInternalFallback( onTextSubmit( props: TTTDDialog.OnTextSubmitProps, ): Promise; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; persistenceAdapter: TTDPersistenceAdapter; } @@ -110,6 +116,7 @@ const TTDDialogBase = withInternalFallback( diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx index b9758f1521..a36d436a06 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTab.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; export const TTDDialogTab = ({ tab, diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx index ec975ff21f..43f2a4d0d5 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabTrigger.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; export const TTDDialogTabTrigger = ({ children, diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx index 509c746991..b185654bd4 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabTriggers.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; export const TTDDialogTabTriggers = ({ children, diff --git a/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx index fe41281062..f009e5c45f 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialogTabs.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import { useRef } from "react"; import { isMemberOf } from "@excalidraw/common"; diff --git a/packages/excalidraw/components/TTDDialog/TTDWelcomeMessage.tsx b/packages/excalidraw/components/TTDDialog/TTDWelcomeMessage.tsx new file mode 100644 index 0000000000..ae213a8b60 --- /dev/null +++ b/packages/excalidraw/components/TTDDialog/TTDWelcomeMessage.tsx @@ -0,0 +1,11 @@ +import { t } from "../../i18n"; + +export const TTDWelcomeMessage = () => { + return ( +
+

{t("chat.placeholder.title")}

+

{t("chat.placeholder.description")}

+

{t("chat.placeholder.hint")}

+
+ ); +}; diff --git a/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx b/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx index e93775a76b..da547602aa 100644 --- a/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx +++ b/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx @@ -35,6 +35,7 @@ import type { const TextToDiagramContent = ({ mermaidToExcalidrawLib, onTextSubmit, + renderWelcomeScreen, renderWarning, persistenceAdapter, }: { @@ -42,6 +43,7 @@ const TextToDiagramContent = ({ onTextSubmit: ( props: TTTDDialog.OnTextSubmitProps, ) => Promise; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; persistenceAdapter: TTDPersistenceAdapter; }) => { @@ -220,6 +222,7 @@ const TextToDiagramContent = ({ onRetry={handleRetry} onViewAsMermaid={onViewAsMermaid} renderWarning={renderWarning} + renderWelcomeScreen={renderWelcomeScreen} /> {showPreview && ( ; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; renderWarning?: TTTDDialog.renderWarning; persistenceAdapter: TTDPersistenceAdapter; }) => { @@ -251,6 +256,7 @@ export const TextToDiagram = ({ diff --git a/packages/excalidraw/components/TTDDialog/types.ts b/packages/excalidraw/components/TTDDialog/types.ts index 9c677c1261..4fe159aeb6 100644 --- a/packages/excalidraw/components/TTDDialog/types.ts +++ b/packages/excalidraw/components/TTDDialog/types.ts @@ -116,4 +116,9 @@ export namespace TTTDDialog { export type renderWarning = ( chatMessage: TChat.ChatMessage, ) => React.ReactNode | undefined; + + export type renderWelcomeScreen = (props: { + /** null if not rate limit data currently available */ + rateLimits: RateLimits | null; + }) => React.ReactNode | undefined; } diff --git a/packages/excalidraw/components/ToolPopover.tsx b/packages/excalidraw/components/ToolPopover.tsx index b1fed1cbf1..853fc9c2f4 100644 --- a/packages/excalidraw/components/ToolPopover.tsx +++ b/packages/excalidraw/components/ToolPopover.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { capitalizeString } from "@excalidraw/common"; -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import { trackEvent } from "../analytics"; diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index 51158c35fb..a9300b3d7a 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -1,4 +1,4 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { useLayoutEffect } from "react"; diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 32c118431e..a49dd51fbb 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -202,9 +202,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { style={{ width: props.appState.width, height: props.appState.height, - cursor: props.appState.viewModeEnabled - ? CURSOR_TYPE.GRAB - : CURSOR_TYPE.AUTO, + cursor: + props.appState.viewModeEnabled && + props.appState.activeTool.type !== "laser" + ? CURSOR_TYPE.GRAB + : CURSOR_TYPE.AUTO, }} width={props.appState.width * props.scale} height={props.appState.height * props.scale} @@ -233,6 +235,7 @@ const getRelevantAppStateProps = ( width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, + activeTool: appState.activeTool, openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, selectedElementIds: appState.selectedElementIds, diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss index 5d0be65461..939ef763ce 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.scss @@ -2,24 +2,35 @@ .excalidraw { .dropdown-menu { - position: absolute; - top: 2.5rem; - margin-top: 0.5rem; max-width: 16rem; + z-index: 1; &--placement-top { - top: auto; - bottom: 100%; - margin-top: 0; margin-bottom: 0.5rem; } + &__submenu-trigger { + &[aria-expanded="true"] { + .dropdown-menu-item { + background-color: var(--button-hover-bg); + } + } + } + + &__submenu-trigger-icon { + margin-left: auto; + opacity: 0.5; + svg g { + stroke-width: 2; + } + } + &--mobile { width: 100%; row-gap: 0.75rem; // When main menu is in the top toolbar, position relative to trigger - &.main-menu-dropdown { + &.main-menu { min-width: 232px; margin-top: 0; margin-bottom: 0; @@ -32,10 +43,6 @@ .dropdown-menu-container { padding: 8px 8px; box-sizing: border-box; - max-height: calc( - 100svh - var(--editor-container-padding) * 2 - 2.25rem - ); - box-shadow: var(--shadow-island); border-radius: var(--border-radius-lg); position: relative; transition: box-shadow 0.5s ease-in-out; @@ -51,14 +58,25 @@ .dropdown-menu-container { background-color: var(--island-bg-color); - overflow-y: auto; - --gap: 2; + display: flex; + flex-direction: column; + gap: 1px; + + box-shadow: var(--box-shadow, var(--shadow-island)); + + max-height: calc(100svh - var(--editor-container-padding) * 2 - 2.25rem); + + @at-root .excalidraw.theme--dark#{&} { + box-shadow: var(--box-shadow, var(--shadow-island)), + 0 0 0 1px rgba(0, 0, 0, 0.15); + } } .dropdown-menu-item-base { display: flex; column-gap: 0.625rem; + padding: 0 0.5rem; font-size: 0.875rem; color: var(--color-on-surface); width: 100%; @@ -115,11 +133,9 @@ .dropdown-menu-item { height: 2rem; - margin: 1px; padding: 0 0.5rem; - width: calc(100% - 2px); background-color: transparent; - border: 1px solid transparent; + border: none; align-items: center; cursor: pointer; border-radius: var(--border-radius-md); @@ -162,7 +178,7 @@ &:active { background-color: var(--button-hover-bg); - border-color: var(--color-brand-active); + box-shadow: 0 0 0 1px var(--color-brand-active); } svg { @@ -223,7 +239,7 @@ } &:active { - border-color: var(--color-primary); + box-shadow: 0 0 0 1px var(--color-primary); } &[disabled] { @@ -235,7 +251,7 @@ } &:active { - border-color: transparent; + box-shadow: none; } @at-root .excalidraw.theme--dark#{&} { diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx index 6f358311a2..5b0ef39c3e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.tsx @@ -1,12 +1,18 @@ import React from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { CLASSES } from "@excalidraw/common"; + import DropdownMenuContent from "./DropdownMenuContent"; import DropdownMenuGroup from "./DropdownMenuGroup"; import DropdownMenuItem from "./DropdownMenuItem"; import DropdownMenuItemCustom from "./DropdownMenuItemCustom"; import DropdownMenuItemLink from "./DropdownMenuItemLink"; import MenuSeparator from "./DropdownMenuSeparator"; +import DropdownMenuSub from "./DropdownMenuSub"; import DropdownMenuTrigger from "./DropdownMenuTrigger"; +import DropdownMenuItemCheckbox from "./DropdownMenuItemCheckbox"; import { getMenuContentComponent, getMenuTriggerComponent, @@ -17,44 +23,47 @@ import "./DropdownMenu.scss"; const DropdownMenu = ({ children, open, - placement, }: { children?: React.ReactNode; open: boolean; - placement?: "top" | "bottom"; }) => { const MenuTriggerComp = getMenuTriggerComponent(children); const MenuContentComp = getMenuContentComponent(children); - - // clone the MenuContentComp to pass the placement prop - const MenuContentCompWithPlacement = + const MenuContentWithState = MenuContentComp && React.isValidElement(MenuContentComp) - ? React.cloneElement(MenuContentComp as React.ReactElement, { - placement, - }) + ? React.cloneElement( + MenuContentComp as React.ReactElement< + React.ComponentProps + >, + { open }, + ) : MenuContentComp; return ( -
- {MenuTriggerComp} - {open && MenuContentCompWithPlacement} -
+ +
+ {MenuTriggerComp} + {MenuContentWithState} +
+
); }; DropdownMenu.Trigger = DropdownMenuTrigger; DropdownMenu.Content = DropdownMenuContent; DropdownMenu.Item = DropdownMenuItem; +DropdownMenu.ItemCheckbox = DropdownMenuItemCheckbox; DropdownMenu.ItemLink = DropdownMenuItemLink; DropdownMenu.ItemCustom = DropdownMenuItemCustom; DropdownMenu.Group = DropdownMenuGroup; DropdownMenu.Separator = MenuSeparator; +DropdownMenu.Sub = DropdownMenuSub; export default DropdownMenu; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index f92c9df327..79be24b969 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -1,7 +1,9 @@ import clsx from "clsx"; -import React, { useEffect, useRef } from "react"; +import React, { useCallback, useEffect, useRef } from "react"; -import { EVENT, KEYS } from "@excalidraw/common"; +import { CLASSES, EVENT, KEYS } from "@excalidraw/common"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { useOutsideClick } from "../../hooks/useOutsideClick"; import { useStable } from "../../hooks/useStable"; @@ -16,8 +18,9 @@ const MenuContent = ({ onClickOutside, className = "", onSelect, + open = true, + align = "end", style, - placement = "bottom", }: { children?: React.ReactNode; onClickOutside?: () => void; @@ -26,26 +29,36 @@ const MenuContent = ({ * Called when any menu item is selected (clicked on). */ onSelect?: (event: Event) => void; + open?: boolean; style?: React.CSSProperties; - placement?: "top" | "bottom"; + align?: "start" | "center" | "end"; }) => { const editorInterface = useEditorInterface(); const menuRef = useRef(null); const callbacksRef = useStable({ onClickOutside }); - useOutsideClick(menuRef, (event) => { - // prevents closing if clicking on the trigger button - if ( - !menuRef.current - ?.closest(".dropdown-menu-container") - ?.contains(event.target) - ) { - callbacksRef.onClickOutside?.(); - } - }); + useOutsideClick( + menuRef, + useCallback( + (event) => { + // prevents closing if clicking on the trigger button + if ( + !menuRef.current + ?.closest(`.${CLASSES.DROPDOWN_MENU_EVENT_WRAPPER}`) + ?.contains(event.target) + ) { + callbacksRef.onClickOutside?.(); + } + }, + [callbacksRef], + ), + ); useEffect(() => { + if (!open) { + return; + } const onKeyDown = (event: KeyboardEvent) => { if (event.key === KEYS.ESCAPE) { event.stopImmediatePropagation(); @@ -63,35 +76,33 @@ const MenuContent = ({ return () => { document.removeEventListener(EVENT.KEYDOWN, onKeyDown, option); }; - }, [callbacksRef]); + }, [callbacksRef, open]); const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": editorInterface.formFactor === "phone", - "dropdown-menu--placement-top": placement === "top", }).trim(); return ( -
event.preventDefault()} > {/* the zIndex ensures this menu has higher stacking order, see https://github.com/excalidraw/excalidraw/pull/1445 */} {editorInterface.formFactor === "phone" ? ( {children} ) : ( - + {children} )} -
+
); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx index 0f227a0bbd..55ffcd5844 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItem.tsx @@ -1,78 +1,62 @@ -import React, { useEffect, useRef } from "react"; +import React from "react"; import { THEME } from "@excalidraw/common"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import type { ValueOf } from "@excalidraw/common/utility-types"; import { useExcalidrawAppState } from "../App"; -import MenuItemContent from "./DropdownMenuItemContent"; import { getDropdownMenuItemClassName, - useHandleDropdownMenuItemClick, + useHandleDropdownMenuItemSelect, } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; import type { JSX } from "react"; -const DropdownMenuItem = ({ - icon, - value, - order, - children, - shortcut, - className, - hovered, - selected, - textStyle, - onSelect, - onClick, - badge, - ...rest -}: { +export type DropdownMenuItemProps = { icon?: JSX.Element; + badge?: React.ReactNode; value?: string | number | undefined; - order?: number; onSelect?: (event: Event) => void; children: React.ReactNode; shortcut?: string; - hovered?: boolean; selected?: boolean; - textStyle?: React.CSSProperties; className?: string; - badge?: React.ReactNode; -} & Omit, "onSelect">) => { - const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect); - const ref = useRef(null); +} & Omit, "onSelect">; - useEffect(() => { - if (hovered) { - if (order === 0) { - // scroll into the first item differently, so it's visible what is above (i.e. group title) - ref.current?.scrollIntoView({ block: "end" }); - } else { - ref.current?.scrollIntoView({ block: "nearest" }); - } - } - }, [hovered, order]); +const DropdownMenuItem = ({ + icon, + badge, + value, + children, + shortcut, + className, + selected, + onSelect, + ...rest +}: DropdownMenuItemProps) => { + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( - + + {children} + + + ); }; DropdownMenuItem.displayName = "DropdownMenuItem"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx new file mode 100644 index 0000000000..c116e5bde7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemCheckbox.tsx @@ -0,0 +1,15 @@ +import { checkIcon, emptyIcon } from "../icons"; + +import DropdownMenuItem from "./DropdownMenuItem"; + +import type { DropdownMenuItemProps } from "./DropdownMenuItem"; + +const DropdownMenuItemCheckbox = ( + props: Omit & { checked: boolean }, +) => { + return ( + + ); +}; + +export default DropdownMenuItemCheckbox; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx index d8177c50e0..4f2986c30e 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx @@ -27,9 +27,7 @@ const DropdownMenuItemContentRadio = ({ return ( <>
- + void; rel?: string; } & React.AnchorHTMLAttributes) => { - const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect); + const handleSelect = useHandleDropdownMenuItemSelect(onSelect); return ( - - - {children} - - + + + {children} + + + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx index 1c6d19521d..4f880c691a 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSeparator.tsx @@ -5,7 +5,7 @@ const MenuSeparator = () => ( style={{ height: "1px", backgroundColor: "var(--default-border-color)", - margin: ".5rem 0", + margin: "6px 0", flex: "0 0 auto", }} /> diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx new file mode 100644 index 0000000000..0ba328b189 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSub.tsx @@ -0,0 +1,26 @@ +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import DropdownMenuSubContent from "./DropdownMenuSubContent"; +import DropdownMenuSubTrigger from "./DropdownMenuSubTrigger"; +import { + getSubMenuContentComponent, + getSubMenuTriggerComponent, +} from "./dropdownMenuUtils"; + +const DropdownMenuSub = ({ children }: { children?: React.ReactNode }) => { + const MenuTriggerComp = getSubMenuTriggerComponent(children); + const MenuContentComp = getSubMenuContentComponent(children); + return ( + + {MenuTriggerComp} + {MenuContentComp} + + ); +}; + +DropdownMenuSub.Trigger = DropdownMenuSubTrigger; +DropdownMenuSub.Content = DropdownMenuSubContent; + +DropdownMenuSub.displayName = "DropdownMenuSub"; + +export default DropdownMenuSub; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx new file mode 100644 index 0000000000..ef5debef57 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubContent.tsx @@ -0,0 +1,71 @@ +import clsx from "clsx"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { useCallback, useState } from "react"; + +import { useEditorInterface } from "../App"; +import { Island } from "../Island"; +import Stack from "../Stack"; + +const BASE_ALIGN_OFFSET = -4; +const BASE_SIDE_OFFSET = 4; + +const DropdownMenuSubContent = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const editorInterface = useEditorInterface(); + + const classNames = clsx(`dropdown-menu dropdown-submenu ${className}`, { + "dropdown-menu--mobile": editorInterface.formFactor === "phone", + }).trim(); + + const callbacksRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const parentContainer = node.closest(".dropdown-menu-container"); + const parentRect = parentContainer?.getBoundingClientRect(); + if (parentRect) { + const menuWidth = node.getBoundingClientRect().width; + + const viewportWidth = window.innerWidth; + const spaceRemaining = viewportWidth - parentRect.right; + if (spaceRemaining < menuWidth + 20) { + setSideOffset(spaceRemaining - menuWidth + BASE_ALIGN_OFFSET); + setAlignOffset(BASE_ALIGN_OFFSET + 8); + } + } + } + }, []); + + const [sideOffset, setSideOffset] = useState(BASE_SIDE_OFFSET); + const [alignOffset, setAlignOffset] = useState(BASE_ALIGN_OFFSET); + + return ( + + {editorInterface.formFactor === "phone" ? ( + {children} + ) : ( + + {children} + + )} + + ); +}; + +export default DropdownMenuSubContent; +DropdownMenuSubContent.displayName = "DropdownMenuSubContent"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx new file mode 100644 index 0000000000..579d4979a7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuSubTrigger.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + +import { chevronRight } from "../icons"; + +import { getDropdownMenuItemClassName } from "./common"; +import MenuItemContent from "./DropdownMenuItemContent"; + +import type { JSX } from "react"; + +const DropdownMenuSubTrigger = ({ + children, + icon, + shortcut, + className, +}: { + children: React.ReactNode; + icon?: JSX.Element; + shortcut?: string; + className?: string; +}) => { + return ( + + + {children} + +
{chevronRight}
+
+ ); +}; + +export default DropdownMenuSubTrigger; +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"; diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx index e1f3ef202f..469eb002b8 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuTrigger.tsx @@ -1,5 +1,7 @@ import clsx from "clsx"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; + import { useEditorInterface } from "../App"; const MenuTrigger = ({ @@ -23,7 +25,7 @@ const MenuTrigger = ({ }, ).trim(); return ( - + ); }; diff --git a/packages/excalidraw/components/dropdownMenu/common.ts b/packages/excalidraw/components/dropdownMenu/common.ts index cf2f1e31f6..27342a28f6 100644 --- a/packages/excalidraw/components/dropdownMenu/common.ts +++ b/packages/excalidraw/components/dropdownMenu/common.ts @@ -1,6 +1,6 @@ import React, { useContext } from "react"; -import { EVENT, composeEventHandlers } from "@excalidraw/common"; +import { composeEventHandlers } from "@excalidraw/common"; export const DropdownMenuContentPropsContext = React.createContext<{ onSelect?: (event: Event) => void; @@ -11,28 +11,17 @@ export const getDropdownMenuItemClassName = ( selected = false, hovered = false, ) => { - return `dropdown-menu-item dropdown-menu-item-base ${className} - ${selected ? "dropdown-menu-item--selected" : ""} ${ - hovered ? "dropdown-menu-item--hovered" : "" - }`.trim(); + return `dropdown-menu-item dropdown-menu-item-base ${className} ${ + selected ? "dropdown-menu-item--selected" : "" + } ${hovered ? "dropdown-menu-item--hovered" : ""}`.trim(); }; -export const useHandleDropdownMenuItemClick = ( - origOnClick: - | React.MouseEventHandler - | undefined, +export const useHandleDropdownMenuItemSelect = ( onSelect: ((event: Event) => void) | undefined, ) => { const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext); - return composeEventHandlers(origOnClick, () => { - const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, { - bubbles: true, - cancelable: true, - }); - onSelect?.(itemSelectEvent); - if (!itemSelectEvent.defaultPrevented) { - DropdownMenuContentProps.onSelect?.(itemSelectEvent); - } + return composeEventHandlers(onSelect, (event) => { + DropdownMenuContentProps.onSelect?.(event); }); }; diff --git a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts index 10d91fb856..82e8ccf591 100644 --- a/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts +++ b/packages/excalidraw/components/dropdownMenu/dropdownMenuUtils.ts @@ -1,6 +1,6 @@ import React from "react"; -export const getMenuTriggerComponent = (children: React.ReactNode) => { +const getMenuComponent = (component: string) => (children: React.ReactNode) => { const comp = React.Children.toArray(children).find( (child) => React.isValidElement(child) && @@ -8,7 +8,7 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { //@ts-ignore child?.type.displayName && //@ts-ignore - child.type.displayName === "DropdownMenuTrigger", + child.type.displayName === component, ); if (!comp) { return null; @@ -17,19 +17,11 @@ export const getMenuTriggerComponent = (children: React.ReactNode) => { return comp; }; -export const getMenuContentComponent = (children: React.ReactNode) => { - const comp = React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && - typeof child.type !== "string" && - //@ts-ignore - child?.type.displayName && - //@ts-ignore - child.type.displayName === "DropdownMenuContent", - ); - if (!comp) { - return null; - } - //@ts-ignore - return comp; -}; +export const getMenuTriggerComponent = getMenuComponent("DropdownMenuTrigger"); +export const getMenuContentComponent = getMenuComponent("DropdownMenuContent"); +export const getSubMenuTriggerComponent = getMenuComponent( + "DropdownMenuSubTrigger", +); +export const getSubMenuContentComponent = getMenuComponent( + "DropdownMenuSubContent", +); diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index ecc4dbafd0..55f0e699b9 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1285,13 +1285,21 @@ export const EdgeRoundIcon = createIcon( tablerIconProps, ); -export const ArrowheadNoneIcon = createIcon( - - - - - , - tablerIconProps, +export const ArrowheadNoneIcon = React.memo( + ({ flip = false }: { flip?: boolean }) => + createIcon( + + + + + , + tablerIconProps, + ), ); export const ArrowheadArrowIcon = React.memo( @@ -2394,3 +2402,32 @@ export const presentationIcon = createIcon( , tablerIconProps, ); + +// empty placeholder icon (used for alignment in menus) +export const emptyIcon =
; + +//tabler-icons: chevron-right +export const chevronRight = createIcon( + + + + , + tablerIconProps, +); + +// tabler-icons: adjustments-horizontal +export const settingsIcon = createIcon( + + + + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/components/main-menu/DefaultItems.tsx b/packages/excalidraw/components/main-menu/DefaultItems.tsx index 29a2761a10..04cea3da48 100644 --- a/packages/excalidraw/components/main-menu/DefaultItems.tsx +++ b/packages/excalidraw/components/main-menu/DefaultItems.tsx @@ -9,9 +9,14 @@ import { actionLoadScene, actionSaveToActiveFile, actionShortcuts, + actionToggleGridMode, + actionToggleObjectsSnapMode, actionToggleSearchMenu, + actionToggleStats, actionToggleTheme, + actionToggleZenMode, } from "../../actions"; +import { actionToggleViewMode } from "../../actions/actionToggleViewMode"; import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { trackEvent } from "../../analytics"; import { useUIAppState } from "../../context/ui-appState"; @@ -23,13 +28,16 @@ import { useExcalidrawActionManager, useExcalidrawElements, useAppProps, + useApp, } from "../App"; import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState"; import Trans from "../Trans"; import DropdownMenuItem from "../dropdownMenu/DropdownMenuItem"; +import DropdownMenuItemCheckbox from "../dropdownMenu/DropdownMenuItemCheckbox"; import DropdownMenuItemContentRadio from "../dropdownMenu/DropdownMenuItemContentRadio"; import DropdownMenuItemLink from "../dropdownMenu/DropdownMenuItemLink"; -import { GithubIcon, DiscordIcon, XBrandIcon } from "../icons"; +import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; +import { GithubIcon, DiscordIcon, XBrandIcon, settingsIcon } from "../icons"; import { boltIcon, DeviceDesktopIcon, @@ -306,10 +314,14 @@ export const ChangeCanvasBackground = () => { return null; } return ( -
+
{t("labels.canvasBackground")}
@@ -393,3 +405,152 @@ export const LiveCollaborationTrigger = ({ }; LiveCollaborationTrigger.displayName = "LiveCollaborationTrigger"; + +const PreferencesToggleToolLockItem = () => { + const { t } = useI18n(); + const app = useApp(); + const appState = useUIAppState(); + + return ( + { + app.toggleLock(); + event.preventDefault(); + }} + > + {t("labels.preferences_toolLock")} + + ); +}; + +const PreferencesToggleSnapModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleObjectsSnapMode); + event.preventDefault(); + }} + > + {t("buttons.objectsSnapMode")} + + ); +}; + +export const PreferencesToggleGridModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + + return ( + { + actionManager.executeAction(actionToggleGridMode); + event.preventDefault(); + }} + > + {t("labels.toggleGrid")} + + ); +}; + +export const PreferencesToggleZenModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleZenMode); + event.preventDefault(); + }} + > + {t("buttons.zenMode")} + + ); +}; + +const PreferencesToggleViewModeItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleViewMode); + event.preventDefault(); + }} + > + {t("labels.viewMode")} + + ); +}; + +const PreferencesToggleElementPropertiesItem = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const appState = useUIAppState(); + return ( + { + actionManager.executeAction(actionToggleStats); + event.preventDefault(); + }} + > + {t("stats.fullTitle")} + + ); +}; + +export const Preferences = ({ + children, + additionalItems, +}: { + children?: React.ReactNode; + additionalItems?: React.ReactNode; +}) => { + const { t } = useI18n(); + return ( + + + {t("labels.preferences")} + + + {children || ( + <> + + + + + + + + )} + {additionalItems} + + + ); +}; + +Preferences.ToggleToolLock = PreferencesToggleToolLockItem; +Preferences.ToggleSnapMode = PreferencesToggleSnapModeItem; +Preferences.ToggleGridMode = PreferencesToggleGridModeItem; +Preferences.ToggleZenMode = PreferencesToggleZenModeItem; +Preferences.ToggleViewMode = PreferencesToggleViewModeItem; +Preferences.ToggleElementProperties = PreferencesToggleElementPropertiesItem; + +Preferences.displayName = "Preferences"; diff --git a/packages/excalidraw/components/main-menu/MainMenu.tsx b/packages/excalidraw/components/main-menu/MainMenu.tsx index d028231328..3755f1f0d4 100644 --- a/packages/excalidraw/components/main-menu/MainMenu.tsx +++ b/packages/excalidraw/components/main-menu/MainMenu.tsx @@ -8,6 +8,7 @@ import { t } from "../../i18n"; import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { UserList } from "../UserList"; import DropdownMenu from "../dropdownMenu/DropdownMenu"; +import DropdownMenuSub from "../dropdownMenu/DropdownMenuSub"; import { withInternalFallback } from "../hoc/withInternalFallback"; import { HamburgerMenuIcon } from "../icons"; @@ -52,12 +53,8 @@ const MainMenu = Object.assign( onSelect={composeEventHandlers(onSelect, () => { setAppState({ openMenu: null }); })} - placement="bottom" - className={ - editorInterface.formFactor === "phone" - ? "main-menu-dropdown" - : "" - } + className="main-menu" + align="start" > {children} {editorInterface.formFactor === "phone" && @@ -84,6 +81,7 @@ const MainMenu = Object.assign( ItemCustom: DropdownMenu.ItemCustom, Group: DropdownMenu.Group, Separator: DropdownMenu.Separator, + Sub: DropdownMenuSub, DefaultItems, }, ); diff --git a/packages/excalidraw/components/shapes.tsx b/packages/excalidraw/components/shapes.tsx index 094d90d651..59c1cd1130 100644 --- a/packages/excalidraw/components/shapes.tsx +++ b/packages/excalidraw/components/shapes.tsx @@ -11,17 +11,28 @@ import { TextIcon, ImageIcon, EraserIcon, + laserPointerToolIcon, + handIcon, } from "./icons"; import type { AppClassProperties } from "../types"; export const SHAPES = [ + { + icon: handIcon, + value: "hand", + key: KEYS.H, + numericKey: null, + fillable: false, + toolbar: true, + }, { icon: SelectionIcon, value: "selection", key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, { icon: RectangleIcon, @@ -29,6 +40,7 @@ export const SHAPES = [ key: KEYS.R, numericKey: KEYS["2"], fillable: true, + toolbar: true, }, { icon: DiamondIcon, @@ -36,6 +48,7 @@ export const SHAPES = [ key: KEYS.D, numericKey: KEYS["3"], fillable: true, + toolbar: true, }, { icon: EllipseIcon, @@ -43,6 +56,7 @@ export const SHAPES = [ key: KEYS.O, numericKey: KEYS["4"], fillable: true, + toolbar: true, }, { icon: ArrowIcon, @@ -50,6 +64,7 @@ export const SHAPES = [ key: KEYS.A, numericKey: KEYS["5"], fillable: true, + toolbar: true, }, { icon: LineIcon, @@ -57,6 +72,7 @@ export const SHAPES = [ key: KEYS.L, numericKey: KEYS["6"], fillable: true, + toolbar: true, }, { icon: FreedrawIcon, @@ -64,6 +80,7 @@ export const SHAPES = [ key: [KEYS.P, KEYS.X], numericKey: KEYS["7"], fillable: false, + toolbar: true, }, { icon: TextIcon, @@ -71,6 +88,7 @@ export const SHAPES = [ key: KEYS.T, numericKey: KEYS["8"], fillable: false, + toolbar: true, }, { icon: ImageIcon, @@ -78,6 +96,7 @@ export const SHAPES = [ key: null, numericKey: KEYS["9"], fillable: false, + toolbar: true, }, { icon: EraserIcon, @@ -85,6 +104,15 @@ export const SHAPES = [ key: KEYS.E, numericKey: KEYS["0"], fillable: false, + toolbar: true, + }, + { + icon: laserPointerToolIcon, + value: "laser", + key: KEYS.K, + numericKey: null, + fillable: false, + toolbar: false, }, ] as const; @@ -97,6 +125,7 @@ export const getToolbarTools = (app: AppClassProperties) => { key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, ...SHAPES.slice(1), ] as const) diff --git a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss index 96f1ca2df3..d3c64c551c 100644 --- a/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss +++ b/packages/excalidraw/components/welcome-screen/WelcomeScreen.scss @@ -29,7 +29,7 @@ // --------------------------------------------------------------------------- .welcome-screen-decor-hint { - @media (max-height: 599px) { + @media (max-height: 780px) { display: none !important; } @@ -148,6 +148,7 @@ .welcome-screen-center__heading { font-size: 1.125rem; text-align: center; + line-height: 1.35rem; } .welcome-screen-menu { diff --git a/packages/excalidraw/css/styles.scss b/packages/excalidraw/css/styles.scss index 10f0d85671..d7492851b0 100644 --- a/packages/excalidraw/css/styles.scss +++ b/packages/excalidraw/css/styles.scss @@ -16,6 +16,7 @@ --zIndex-ui-context-menu: 90; --zIndex-ui-styles-popup: 100; --zIndex-ui-top: 100; + --zIndex-ui-main-menu: 110; --zIndex-ui-library: 120; --zIndex-modal: 1000; @@ -224,6 +225,18 @@ body.excalidraw-cursor-resize * { box-shadow: 0 0 0 1px var(--color-brand-hover); } + // radix doesn't allow differntiating between hover and keyboard active + // states (it's forcing :focus on both). + // + // proper handling would be to disable :focus-visible by default, and enable + // on keyboard arrows (it'd then have to be disabled again, e.g. on keydown + // or container focus) + // + // alas, that is left for another day + [data-radix-collection-item]:focus-visible { + box-shadow: none !important; + } + .buttonList { .ToolIcon__icon { all: unset !important; @@ -677,6 +690,9 @@ body.excalidraw-cursor-resize * { } } + .main-menu { + z-index: var(--zIndex-ui-main-menu); + } .main-menu-trigger { @include filledButtonOnCanvas; } diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index f078ab2dee..a5d55deb4a 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -82,7 +82,12 @@ import { getNormalizedZoom, } from "../scene"; -import type { AppState, BinaryFiles, LibraryItem } from "../types"; +import type { + AppState, + BinaryFiles, + LibraryItem, + NormalizedZoomValue, +} from "../types"; import type { ImportedDataState, LegacyAppState } from "./types"; type RestoredAppState = Omit< @@ -150,7 +155,7 @@ const repairBinding = ( | ExcalidrawElbowArrowElement["startBinding"] | ExcalidrawElbowArrowElement["endBinding"] = { ...binding, - fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), + fixedPoint: normalizeFixedPoint(binding.fixedPoint), mode: binding.mode || "orbit", }; @@ -171,7 +176,7 @@ const repairBinding = ( return { elementId: binding.elementId, mode: binding.mode, - fixedPoint: normalizeFixedPoint(binding.fixedPoint || [0.5, 0.5]), + fixedPoint: normalizeFixedPoint(binding.fixedPoint), } as FixedPointBinding | null; } return null; @@ -180,15 +185,14 @@ const repairBinding = ( // binding schema v1 (legacy) -> attempt to migrate to v2 // --------------------------------------------------------------------------- - const targetBoundElement = - (targetElementsMap.get(binding.elementId) as ExcalidrawBindableElement) || - undefined; + const targetBoundElement = targetElementsMap.get(binding.elementId) as + | ExcalidrawBindableElement + | undefined; const boundElement = targetBoundElement || - (existingElementsMap?.get( - binding.elementId, - ) as ExcalidrawBindableElement) || - undefined; + (existingElementsMap?.get(binding.elementId) as + | ExcalidrawBindableElement + | undefined); const elementsMap = targetBoundElement ? targetElementsMap : existingElementsMap; @@ -203,18 +207,36 @@ const repairBinding = ( const mode = isPointInElement(p, boundElement, elementsMap) ? "inside" : "orbit"; + const safeElement = { + ...element, + startBinding: element.startBinding?.elementId + ? { + ...element.startBinding, + mode, + fixedPoint: normalizeFixedPoint(element.startBinding.fixedPoint), + } + : null, + endBinding: element.endBinding?.elementId + ? { + ...element.endBinding, + mode, + fixedPoint: normalizeFixedPoint(element.endBinding.fixedPoint), + } + : null, + }; const focusPoint = mode === "inside" ? p : projectFixedPointOntoDiagonal( - element, + safeElement, p, boundElement, startOrEnd, elementsMap, + { value: 1 as NormalizedZoomValue }, ) || p; const { fixedPoint } = calculateFixedPointForNonElbowArrowBinding( - element, + safeElement, boundElement, startOrEnd, elementsMap, diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index db0fe94cd8..f8004b4c2f 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -317,3 +317,5 @@ export { getDataURL } from "./data/blob"; export { isElementLink } from "@excalidraw/element"; export { setCustomTextMetricsProvider } from "@excalidraw/element"; + +export { CommandPalette } from "./components/CommandPalette/CommandPalette"; diff --git a/packages/excalidraw/locales/ar-SA.json b/packages/excalidraw/locales/ar-SA.json index e2380a942c..e135979ad0 100644 --- a/packages/excalidraw/locales/ar-SA.json +++ b/packages/excalidraw/locales/ar-SA.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "جميع بياناتك محفوظة محليًا في المتصفح الخاص بك.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "هل تريد الانتقال إلى Excalidraw+ بدلاً من ذلك؟", "menuHint": "التصدير، التفضيلات، اللغات..." }, @@ -612,7 +614,55 @@ "button": "إدراج", "description": "حاليًا، يتم دعم مخططات التدفق، التسلسلات، والفئات فقط. سيتم عرض الأنواع الأخرى كصورة في Excalidraw.", "syntax": "صيغة Mermaid", - "preview": "معاينة" + "preview": "معاينة", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "بحث سريع" diff --git a/packages/excalidraw/locales/az-AZ.json b/packages/excalidraw/locales/az-AZ.json index 2a9a654c80..dc9237e44f 100644 --- a/packages/excalidraw/locales/az-AZ.json +++ b/packages/excalidraw/locales/az-AZ.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/bg-BG.json b/packages/excalidraw/locales/bg-BG.json index eafef1c9d8..118733d7c9 100644 --- a/packages/excalidraw/locales/bg-BG.json +++ b/packages/excalidraw/locales/bg-BG.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Всичките Ви данни са запазени локално в браузъра Ви.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "Експорт, предпочитания, езици, ..." }, @@ -612,7 +614,55 @@ "button": "Вмъкни", "description": "", "syntax": "Mermaid Синтаксис", - "preview": "Преглед" + "preview": "Преглед", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/bn-BD.json b/packages/excalidraw/locales/bn-BD.json index a6d48de209..3b69089413 100644 --- a/packages/excalidraw/locales/bn-BD.json +++ b/packages/excalidraw/locales/bn-BD.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/bn-IN.json b/packages/excalidraw/locales/bn-IN.json index a6d48de209..3b69089413 100644 --- a/packages/excalidraw/locales/bn-IN.json +++ b/packages/excalidraw/locales/bn-IN.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/ca-ES.json b/packages/excalidraw/locales/ca-ES.json index cfb463e404..f674375148 100644 --- a/packages/excalidraw/locales/ca-ES.json +++ b/packages/excalidraw/locales/ca-ES.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Totes les vostres dades es guarden localment al vostre navegador.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Vols anar a Excalidraw+ en comptes?", "menuHint": "Exportar, preferències, llenguatges..." }, @@ -612,7 +614,55 @@ "button": "Inseriu", "description": "Actualment només s'admeten els diagrames Flowchart, Sequence, i Class . Els altres tipus es representaran com a imatge a Excalidraw.", "syntax": "Sintaxi de Mermaid", - "preview": "Previsualització" + "preview": "Previsualització", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Cerca ràpida" diff --git a/packages/excalidraw/locales/cs-CZ.json b/packages/excalidraw/locales/cs-CZ.json index 3d93b1cd83..6c0f84afea 100644 --- a/packages/excalidraw/locales/cs-CZ.json +++ b/packages/excalidraw/locales/cs-CZ.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Chcete místo toho přejít na Excalidraw+?", "menuHint": "Export, nastavení, jazyky, ..." }, @@ -612,7 +614,55 @@ "button": "Vložit", "description": "", "syntax": "Mermaid syntaxe", - "preview": "Náhled" + "preview": "Náhled", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Rychlé vyhledávání" diff --git a/packages/excalidraw/locales/da-DK.json b/packages/excalidraw/locales/da-DK.json index 111aa91261..5915105db6 100644 --- a/packages/excalidraw/locales/da-DK.json +++ b/packages/excalidraw/locales/da-DK.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/de-CH.json b/packages/excalidraw/locales/de-CH.json index c8148eb49a..047fc98e4b 100644 --- a/packages/excalidraw/locales/de-CH.json +++ b/packages/excalidraw/locales/de-CH.json @@ -196,7 +196,7 @@ "multipleResults": "Ergebnisse", "placeholder": "Text auf Zeichenfläche suchen...", "frames": "", - "texts": "" + "texts": "Texte" }, "buttons": { "clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen", @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Alle Daten werden lokal in Deinem Browser gespeichert.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Möchtest du stattdessen zu Excalidraw+ gehen?", "menuHint": "Exportieren, Einstellungen, Sprachen, ..." }, @@ -569,7 +571,7 @@ } }, "colorPicker": { - "color": "", + "color": "Farbe", "mostUsedCustomColors": "Beliebteste benutzerdefinierte Farben", "colors": "Farben", "shades": "Schattierungen", @@ -612,7 +614,55 @@ "button": "Einfügen", "description": "Derzeit werden nur Flussdiagramme, Sequenzdiagramme und Klassendiagramme unterstützt. Die anderen Typen werden als Bild in Excalidraw dargestellt.", "syntax": "Mermaid-Syntax", - "preview": "Vorschau" + "preview": "Vorschau", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Schnellsuche" @@ -651,15 +701,15 @@ "shortcutHint": "Benutze {{shortcut}} für Befehlspalette" }, "keys": { - "ctrl": "", + "ctrl": "Strg", "option": "", "cmd": "", "alt": "", "escape": "", "enter": "", "shift": "", - "spacebar": "", - "delete": "", - "mmb": "" + "spacebar": "Leertaste", + "delete": "Löschen", + "mmb": "Mausrad" } } diff --git a/packages/excalidraw/locales/de-DE.json b/packages/excalidraw/locales/de-DE.json index 719d91c526..047fc98e4b 100644 --- a/packages/excalidraw/locales/de-DE.json +++ b/packages/excalidraw/locales/de-DE.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Alle Daten werden lokal in Deinem Browser gespeichert.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Möchtest du stattdessen zu Excalidraw+ gehen?", "menuHint": "Exportieren, Einstellungen, Sprachen, ..." }, @@ -612,7 +614,55 @@ "button": "Einfügen", "description": "Derzeit werden nur Flussdiagramme, Sequenzdiagramme und Klassendiagramme unterstützt. Die anderen Typen werden als Bild in Excalidraw dargestellt.", "syntax": "Mermaid-Syntax", - "preview": "Vorschau" + "preview": "Vorschau", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Schnellsuche" diff --git a/packages/excalidraw/locales/el-GR.json b/packages/excalidraw/locales/el-GR.json index 5154329b21..b8c93b1112 100644 --- a/packages/excalidraw/locales/el-GR.json +++ b/packages/excalidraw/locales/el-GR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Όλα τα δεδομένα σας αποθηκεύονται τοπικά στο πρόγραμμα περιήγησης.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Μήπως θέλατε να πάτε στο Excalidraw+;", "menuHint": "Εξαγωγή, προτιμήσεις, γλώσσες, ..." }, @@ -612,7 +614,55 @@ "button": "Εισαγωγή", "description": "Επί του παρόντος υποστηρίζονται μόνο Διαγράμματα Ροής, Ακολουθίας, και Κλάσεων. Οι άλλοι τύποι θα αποδοθούν ως εικόνα στο Excalidraw.", "syntax": "Σύνταξη Mermaid", - "preview": "Προεπισκόπηση" + "preview": "Προεπισκόπηση", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Γρήγορη αναζήτηση" diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b66485c73d..182e59bb61 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -171,7 +171,9 @@ "linkToElement": "Link to object", "wrapSelectionInFrame": "Wrap selection in frame", "tab": "Tab", - "shapeSwitch": "Switch shape" + "shapeSwitch": "Switch shape", + "preferences": "Preferences", + "preferences_toolLock": "Tool lock" }, "elementLink": { "title": "Link to object", @@ -557,7 +559,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "All your data is saved locally in your browser.", + "center_heading": "Your drawings are saved in your browser's storage.", + "center_heading_line2": "Browser storage can be cleared unexpectedly.", + "center_heading_line3": "Save your work to a file regularly to avoid losing it.", "center_heading_plus": "Did you want to go to the Excalidraw+ instead?", "menuHint": "Export, preferences, languages, ..." }, diff --git a/packages/excalidraw/locales/es-ES.json b/packages/excalidraw/locales/es-ES.json index c05da9af51..811d5b77e7 100644 --- a/packages/excalidraw/locales/es-ES.json +++ b/packages/excalidraw/locales/es-ES.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Toda su información es guardada localmente en su navegador.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "¿Quieres ir a Excalidraw+?", "menuHint": "Exportar, preferencias, idiomas, ..." }, @@ -612,7 +614,55 @@ "button": "Insertar", "description": "Actualmente sólo estos tipos de diagrama de flujo, Secuencia, y Clase son soportados. Los otros tipos de diagramas se renderizarán como imagen en Excalidraw.", "syntax": "Sintaxis Mermaid", - "preview": "Vista previa" + "preview": "Vista previa", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Búsqueda rápida" diff --git a/packages/excalidraw/locales/eu-ES.json b/packages/excalidraw/locales/eu-ES.json index b6f4d7e32f..e4653bb482 100644 --- a/packages/excalidraw/locales/eu-ES.json +++ b/packages/excalidraw/locales/eu-ES.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Zure datu guztiak lokalean gordetzen dira zure nabigatzailean.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Horren ordez Excalidraw+-era joan nahi al zenuen?", "menuHint": "Esportatu, hobespenak, hizkuntzak..." }, @@ -612,7 +614,55 @@ "button": "Txertatu", "description": "Momentu honetan Flowchart, Sequence, eta Class Diagramak onartzen dira. Beste motak irudi gisa errendatuko dira Excalidrawn.", "syntax": "Mermaid sintaxia", - "preview": "Aurrebista" + "preview": "Aurrebista", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/fa-IR.json b/packages/excalidraw/locales/fa-IR.json index ac3c553073..1bff7da6ca 100644 --- a/packages/excalidraw/locales/fa-IR.json +++ b/packages/excalidraw/locales/fa-IR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "تمام داده های شما به صورت محلی در مرورگر شما ذخیره می شود.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "آیا می‌خواهید به جای آن به Excalidraw+ بروید؟", "menuHint": "خروجی، ترجیحات، زبان ها، ..." }, @@ -612,7 +614,55 @@ "button": "درج", "description": "فعلا فقط فلوچارت ، توالی و کلاس نمودارها پشتیبانی می شوند. انواع دیگر به صورت تصویر در Excalidraw ارائه خواهند شد.", "syntax": "مرمید syntax", - "preview": "پیشنمایش" + "preview": "پیشنمایش", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "جستجو فوری" diff --git a/packages/excalidraw/locales/fi-FI.json b/packages/excalidraw/locales/fi-FI.json index d6052b672c..09742c471e 100644 --- a/packages/excalidraw/locales/fi-FI.json +++ b/packages/excalidraw/locales/fi-FI.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Kaikki tietosi on tallennettu paikallisesti selaimellesi.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Haluatko sen sijaan mennä Excalidraw+:aan?", "menuHint": "Vie, asetukset, kielet, ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "Esikatsele" + "preview": "Esikatsele", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/fr-FR.json b/packages/excalidraw/locales/fr-FR.json index 8ec8c1282b..8bb520b660 100644 --- a/packages/excalidraw/locales/fr-FR.json +++ b/packages/excalidraw/locales/fr-FR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Toutes vos données sont sauvegardées en local dans votre navigateur.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Vouliez-vous plutôt aller à Excalidraw+ à la place ?", "menuHint": "Exportation, préférences, langues, ..." }, @@ -612,7 +614,55 @@ "button": "Insérer", "description": "Actuellement, seuls les diagrammes Flowchart, Sequence, et de classe sont pris en charge. Les autres types seront rendus en tant qu'image dans Excalidraw.", "syntax": "Syntaxe Mermaid", - "preview": "Prévisualisation" + "preview": "Prévisualisation", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Recherche rapide" diff --git a/packages/excalidraw/locales/gl-ES.json b/packages/excalidraw/locales/gl-ES.json index bcfba4210b..fd1dc3d385 100644 --- a/packages/excalidraw/locales/gl-ES.json +++ b/packages/excalidraw/locales/gl-ES.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Toda a información é gardada de maneira local no seu navegador.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Queres ir a Excalidraw+ no seu lugar?", "menuHint": "Exportar, preferencias, idiomas, ..." }, @@ -612,7 +614,55 @@ "button": "Inserir", "description": "", "syntax": "", - "preview": "Vista previa" + "preview": "Vista previa", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/he-IL.json b/packages/excalidraw/locales/he-IL.json index 2e8be018e8..d09748da14 100644 --- a/packages/excalidraw/locales/he-IL.json +++ b/packages/excalidraw/locales/he-IL.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "כל המידע שלח נשמר מקומית בדפדפן.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "אתה רוצה ללכת אל Excalidraw+ במקום?", "menuHint": "ייצוא, העדפות, שפות, ..." }, @@ -612,7 +614,55 @@ "button": "הוספה", "description": "לעת עתה נתמכים רק תרשימי זרימה, תהליכים, ודיאגרמת מחלקה. שאר הסוגים ייוצרו כתמונות ב-Excalidraw.", "syntax": "תחביר Mermaid", - "preview": "תצוגה מקדימה" + "preview": "תצוגה מקדימה", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "חיפוש מהיר" diff --git a/packages/excalidraw/locales/hi-IN.json b/packages/excalidraw/locales/hi-IN.json index 752f58501b..672dc1074d 100644 --- a/packages/excalidraw/locales/hi-IN.json +++ b/packages/excalidraw/locales/hi-IN.json @@ -143,7 +143,7 @@ }, "polygon": { "breakPolygon": "", - "convertToPolygon": "" + "convertToPolygon": "बहुभुज में कनवर्ट करें" }, "elementLock": { "lock": "ताले में रखें", @@ -171,7 +171,7 @@ "linkToElement": "वस्तु की कड़ी", "wrapSelectionInFrame": "चौकट में चुने हुवे को लपेटे", "tab": "", - "shapeSwitch": "" + "shapeSwitch": "आकार बदलें" }, "elementLink": { "title": "वस्तु की कड़ी", @@ -183,9 +183,9 @@ "hint_emptyLibrary": "यहाँ जोड़ने के लिए चित्रपटल से एक अवयव चुने, अथवा जन कोष से एक संग्रह नीचे स्थापित करें.", "hint_emptyPrivateLibrary": "यहाँ जोड़ने के लिए चित्रपटल से एक अवयव चुने.", "search": { - "inputPlaceholder": "", - "heading": "", - "noResults": "", + "inputPlaceholder": "लाइब्रेरी में खोजें", + "heading": "लाइब्रेरी मैच", + "noResults": "कोई मिलता जुलता नहीं मिला |||", "clearSearch": "" } }, @@ -195,8 +195,8 @@ "singleResult": "परिणाम", "multipleResults": "परिणाम", "placeholder": "पटल पर पाठ्य धूंडे", - "frames": "", - "texts": "" + "frames": "फ्रेम्स", + "texts": "शब्द" }, "buttons": { "clearReset": "चित्रपटल स्वच्छ करें", @@ -292,7 +292,7 @@ }, "toolBar": { "selection": "चयन", - "lasso": "", + "lasso": "लासो सलेक्शन", "image": "प्रतिमा सम्मिलित करें", "rectangle": "आयात", "diamond": "ईंट", @@ -313,7 +313,7 @@ "hand": "हाथ ( खिसका के देखने का औज़ार)", "extraTools": "अधिक उपकरण", "mermaidToExcalidraw": "मर्मेड से एक्सकाली में", - "convertElementType": "" + "convertElementType": "आकार प्रकार टॉगल करें" }, "element": { "rectangle": "आयत", @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "आपका सर्व डेटा ब्राउज़र के भीतर स्थानिक जगह पे सुरक्षित किया गया.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "बजाय आपको एक्स-काली-ड्रॉ-प्लस पर जाना है?", "menuHint": "निर्यात, पसंद, भाषायें, ..." }, @@ -612,7 +614,55 @@ "button": "सन्निवेश करे", "description": "वर्तमान में केवल बहाव चित्र, अनुक्रम चित्र और वर्ग चित्र का चित्रिकरण संभव हैं. अन्य चित्र प्रकार एक्सकाली प्रतिमा जैसे चित्रित किए जायेंगे.", "syntax": "मर्मेड विन्यास", - "preview": "पूर्वावलोकन" + "preview": "पूर्वावलोकन", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "त्वरित खोज" diff --git a/packages/excalidraw/locales/hu-HU.json b/packages/excalidraw/locales/hu-HU.json index 92b8f479c6..e1ca50118f 100644 --- a/packages/excalidraw/locales/hu-HU.json +++ b/packages/excalidraw/locales/hu-HU.json @@ -11,8 +11,8 @@ "copyAsPng": "Vágólapra másolás mint PNG", "copyAsSvg": "Vágólapra másolás mint SVG", "copyText": "Vágólapra másolás szövegként", - "copySource": "", - "convertToCode": "", + "copySource": "Forrás másolása a vágólapra", + "convertToCode": "Kód generálás", "bringForward": "Előrébb hozás", "sendToBack": "Hátraküldés", "bringToFront": "Előrehozás", @@ -86,7 +86,7 @@ "layers": "Rétegek", "actions": "Műveletek", "language": "Nyelv", - "liveCollaboration": "", + "liveCollaboration": "Élő együttműködés...", "duplicateSelection": "Duplikálás", "untitled": "Névtelen", "name": "Név", @@ -95,13 +95,13 @@ "group": "Csoportosítás", "ungroup": "Csoportbontás", "collaborators": "Közreműködők", - "toggleGrid": "", + "toggleGrid": "Keresés törlése", "addToLibrary": "Hozzáadás a könyvtárhoz", "removeFromLibrary": "Eltávólítás a könyvtárból", "libraryLoadingMessage": "Könyvtár betöltése…", "libraries": "Könyvtárak böngészése", "loadingScene": "Jelenet betöltése…", - "loadScene": "", + "loadScene": "Betöltés fájlból", "align": "Igazítás", "alignTop": "Felülre igazítás", "alignBottom": "Alulra igazítás", @@ -118,7 +118,7 @@ "showStroke": "Körvonal színválasztó megjelenítése", "showBackground": "Háttérszín-választó megjelenítése", "showFonts": "", - "toggleTheme": "", + "toggleTheme": "Világos/sötét háttér kapcsoló", "theme": "Téma", "personalLib": "Személyes könyvtár", "excalidrawLib": "Excalidraw könyvtár", @@ -126,7 +126,7 @@ "increaseFontSize": "Betűméret növelése", "unbindText": "Szövegkötés feloldása", "bindText": "", - "createContainerFromText": "", + "createContainerFromText": "Szöveg bekeretezése", "link": { "edit": "Hivatkozás szerkesztése", "editEmbed": "", @@ -147,34 +147,34 @@ }, "elementLock": { "lock": "", - "unlock": "", + "unlock": "Zárolás feloldása", "lockAll": "Összes zárolása", "unlockAll": "Összes feloldása" }, "statusPublished": "Közzétéve", - "sidebarLock": "", + "sidebarLock": "Oldalsó sáv nyitva tartása", "selectAllElementsInFrame": "", "removeAllElementsFromFrame": "", "eyeDropper": "", "textToDiagram": "Szövegből diagram", - "prompt": "", + "prompt": "Egyéb", "followUs": "Kövess minket", "discordChat": "Discord chat", "zoomToFitViewport": "", - "zoomToFitSelection": "", - "zoomToFit": "", + "zoomToFitSelection": "Nagyítás a kijelölés méretére", + "zoomToFit": "Az összes elem látótérbe hozása", "installPWA": "", "autoResize": "", "imageCropping": "", "unCroppedDimension": "", "copyElementLink": "", - "linkToElement": "", + "linkToElement": "Hivatkozás az objektumhoz", "wrapSelectionInFrame": "", - "tab": "", + "tab": "Tab", "shapeSwitch": "" }, "elementLink": { - "title": "", + "title": "Hivatkozás az objektumhoz", "desc": "", "notFound": "" }, @@ -183,31 +183,31 @@ "hint_emptyLibrary": "", "hint_emptyPrivateLibrary": "", "search": { - "inputPlaceholder": "", + "inputPlaceholder": "Keresés a könyvtárban", "heading": "", "noResults": "", - "clearSearch": "" + "clearSearch": "Keresés törlése" } }, "search": { - "title": "", + "title": "Rajzvászonon használt", "noMatch": "", "singleResult": "", "multipleResults": "", "placeholder": "", - "frames": "", - "texts": "" + "frames": "Keretek", + "texts": "Szövegek" }, "buttons": { "clearReset": "Vászon törlése", "exportJSON": "Exportálás fájlba", - "exportImage": "", - "export": "", + "exportImage": "Kép exportálása...", + "export": "Mentés másként...", "copyToClipboard": "Vágólapra másolás", - "copyLink": "", + "copyLink": "Link másolása", "save": "Mentés az aktuális fájlba", "saveAs": "Mentés másként", - "load": "", + "load": "Megnyitás", "getShareableLink": "Megosztható link létrehozása", "close": "Bezárás", "selectLanguage": "Nyelv kiválasztása", @@ -225,19 +225,19 @@ "fullScreen": "Teljes képernyő", "darkMode": "Sötét mód", "lightMode": "Világos mód", - "systemMode": "", + "systemMode": "Rendszer mód", "zenMode": "Letisztult mód", - "objectsSnapMode": "", + "objectsSnapMode": "Objektumhoz illeszt", "exitZenMode": "Kilépés a letisztult módból", "cancel": "Mégsem", "saveLibNames": "", "clear": "Kiűrítés", "remove": "Eltávolítás", - "embed": "", + "embed": "Beágyazás be/ki", "publishLibrary": "", "submit": "Elküldés", "confirm": "Megerősítés", - "embeddableInteractionButton": "" + "embeddableInteractionButton": "Interakció Kattintással" }, "alerts": { "clearReset": "Ez a művelet törli a vászont. Biztos benne?", @@ -246,7 +246,7 @@ "couldNotLoadInvalidFile": "Nem sikerült betölteni a helytelen fájlt", "importBackendFailed": "Nem sikerült betölteni a szerverről.", "cannotExportEmptyCanvas": "Üres vászont nem lehet exportálni.", - "couldNotCopyToClipboard": "", + "couldNotCopyToClipboard": "Nem lehet a vágólapra másolni.", "decryptFailed": "Nem sikerült visszafejteni a titkosított adatot.", "uploadedSecurly": "A feltöltést végpontok közötti titkosítással biztosítottuk, ami azt jelenti, hogy egy harmadik fél nem tudja megnézni a tartalmát, beleértve az Excalidraw szervereit is.", "loadSceneOverridePrompt": "A betöltött külső rajz felül fogja írnia meglévőt. Szeretnéd folytatni?", @@ -304,31 +304,31 @@ "library": "Könyvtár", "lock": "Rajzolás után az aktív eszközt tartsa kijelölve", "penMode": "", - "link": "", - "eraser": "", + "link": "Hivatkozás hozzáadása/frissítése a kiválasztott alakzathoz", + "eraser": "Radír", "frame": "", "magicframe": "", "embeddable": "", - "laser": "", + "laser": "Lézermutató", "hand": "", - "extraTools": "", + "extraTools": "További eszközök", "mermaidToExcalidraw": "", "convertElementType": "" }, "element": { - "rectangle": "", - "diamond": "", - "ellipse": "", - "arrow": "", - "line": "", + "rectangle": "Téglalap", + "diamond": "Rombusz", + "ellipse": "Ellipszis", + "arrow": "Nyíl", + "line": "Vonal", "freedraw": "", - "text": "", - "image": "", - "group": "", - "frame": "", + "text": "Szöveg", + "image": "Kép", + "group": "Csoport", + "frame": "Keret", "magicframe": "", - "embeddable": "", - "selection": "", + "embeddable": "Weblap beágyazása", + "selection": "Kijelölés", "iframe": "" }, "headings": { @@ -338,7 +338,7 @@ }, "hints": { "dismissSearch": "", - "canvasPanning": "", + "canvasPanning": "A vászon mozgatásához tartsd lenyomva a {{shortcut_1}} vagy {{shortcut_2}} billentyűt húzás közben, vagy használd a kéz eszközt", "linearElement": "Kattintással görbe, az eger húzásával pedig egyenes nyilat rajzolhatsz", "arrowTool": "", "arrowBindModifiers": "", @@ -380,7 +380,7 @@ "sceneContent": "Jelenet tartalma:" }, "shareDialog": { - "or": "" + "or": "Vagy" }, "roomDialog": { "desc_intro": "", @@ -420,7 +420,7 @@ "drag": "vonszolás", "editor": "Szerkesztő", "editLineArrowPoints": "", - "editText": "", + "editText": "Szöveg szerkesztése / címke hozzáadása", "github": "Hibát találtál? Küld be", "howto": "Kövesd az útmutatóinkat", "or": "vagy", @@ -436,7 +436,7 @@ "toggleElementLock": "", "movePageUpDown": "", "movePageLeftRight": "", - "cropStart": "", + "cropStart": "Kép kivágása", "cropFinish": "" }, "clearCanvasDialog": { @@ -481,25 +481,25 @@ "imageExportDialog": { "header": "Kép exportálása", "label": { - "withBackground": "", - "onlySelected": "", - "darkMode": "", - "embedScene": "", - "scale": "", - "padding": "" + "withBackground": "Háttér", + "onlySelected": "Csak a kijelölt", + "darkMode": "Sötét mód", + "embedScene": "Jelenet beágyazása", + "scale": "Nagyítás", + "padding": "Eltartás" }, "tooltip": { - "embedScene": "" + "embedScene": "A jelenetet leíró adatok hozzá lesznek adva a PNG/SVG fájlhoz, így a jelenetet vissza lehet majd tölteni belőle. Ez megnöveli a fájl méretét." }, "title": { - "exportToPng": "", - "exportToSvg": "", - "copyPngToClipboard": "" + "exportToPng": "Exportálás PNG-be", + "exportToSvg": "Exportálás SVG-be", + "copyPngToClipboard": "PNG másolása a vágólapra" }, "button": { - "exportToPng": "", - "exportToSvg": "", - "copyPngToClipboard": "" + "exportToPng": "PNG", + "exportToSvg": "SVG", + "copyPngToClipboard": "Vágólapra másolás" } }, "encrypted": { @@ -508,14 +508,14 @@ }, "stats": { "angle": "Szög", - "shapes": "", + "shapes": "Alakzatok", "height": "Magasság", "scene": "Jelenet", "selected": "Kijelölt", "storage": "Tárhely", "fullTitle": "", - "title": "", - "generalStats": "", + "title": "Tulajdonságok", + "generalStats": "Általános", "elementProperties": "", "total": "Összesen", "version": "Verzió", @@ -540,24 +540,26 @@ }, "colors": { "transparent": "Átlátszó", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Fekete", + "white": "Fehér", + "red": "Piros", + "pink": "Pink", + "grape": "Szőlő", + "violet": "Lila", + "gray": "Szürke", + "blue": "Kék", + "cyan": "Cián", + "teal": "Türkiz", + "green": "Zöld", + "yellow": "Sárga", + "orange": "Narancssárga", + "bronze": "Bronz" }, "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -569,7 +571,7 @@ } }, "colorPicker": { - "color": "", + "color": "Szín", "mostUsedCustomColors": "", "colors": "", "shades": "", @@ -612,18 +614,66 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "Te", + "assistant": "AI asszisztens", + "system": "Rendszer" + }, + "aiBeta": "", + "label": "Csevegés", + "menu": "Menü", + "newChat": "Új csevegés", + "deleteChat": "Csevegés törlése", + "deleteMessage": "Üzenet törlése", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "Előnézet", + "insert": "Beszúrás", + "retry": "Újra", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "Frissítés Plus csomagra" }, "quickSearch": { - "placeholder": "" + "placeholder": "Gyorskeresés" }, "fontList": { "badge": { - "old": "" + "old": "régi" }, "sceneFonts": "", - "availableFonts": "", - "empty": "" + "availableFonts": "Elérhető betűtípusok", + "empty": "Nem találhatóak betűtípusok" }, "userList": { "empty": "", @@ -636,13 +686,13 @@ } }, "commandPalette": { - "title": "", + "title": "Parancspanel", "shortcuts": { - "select": "", - "confirm": "", - "close": "" + "select": "Kiválasztás", + "confirm": "Megerősítés", + "close": "Bezárás" }, - "recents": "", + "recents": "Legutóbb használt", "search": { "placeholder": "", "noMatch": "" @@ -651,8 +701,8 @@ "shortcutHint": "" }, "keys": { - "ctrl": "", - "option": "", + "ctrl": "Ctrl", + "option": "Option", "cmd": "", "alt": "", "escape": "", diff --git a/packages/excalidraw/locales/id-ID.json b/packages/excalidraw/locales/id-ID.json index 11d7fa7ba5..023f2c4319 100644 --- a/packages/excalidraw/locales/id-ID.json +++ b/packages/excalidraw/locales/id-ID.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Semua data Anda disimpan secara lokal di peramban Anda.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Apa Anda ingin berpindah ke Excalidraw+?", "menuHint": "Ekspor, preferensi, bahasa, ..." }, @@ -612,7 +614,55 @@ "button": "Sisipkan", "description": "Saat ini hanya Flowchart, Sekuen, , dan KelasDiagram yang didukung. Jenis lainnya akan dirender sebagai gambar di Excalidraw.", "syntax": "Syntax Mermaid", - "preview": "Pratinjau" + "preview": "Pratinjau", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Pencarian Cepat" diff --git a/packages/excalidraw/locales/it-IT.json b/packages/excalidraw/locales/it-IT.json index f6c73d938c..0109401424 100644 --- a/packages/excalidraw/locales/it-IT.json +++ b/packages/excalidraw/locales/it-IT.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Tutti i tuoi dati sono salvati localmente nel browser.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Volevi invece andare su Excalidraw+?", "menuHint": "Esporta, preferenze, lingue, ..." }, @@ -612,7 +614,55 @@ "button": "Inserisci", "description": "Attualmente sono supportati solo diagrammi di flusso, sequenza, e classe . Gli altri tipi saranno rappresentati come immagini in Excalidraw.", "syntax": "Sintassi Mermaid", - "preview": "Anteprima" + "preview": "Anteprima", + "label": "Mermaid", + "inputPlaceholder": "Scrivi qui la definizione del diagramma Mermaid..." + }, + "ttd": { + "error": "Errore!" + }, + "chat": { + "inputPlaceholder": "Inizia a digitare qui la tua idea per il diagramma... ({{shortcut}} per una nuova riga)", + "inputPlaceholderWithMessages": "Continua a perfezionare il tuo diagramma...", + "generating": "Generazione in corso...", + "rateLimitRemaining": "{{count}} richieste rimaste oggi", + "role": { + "user": "Tu", + "assistant": "Assistente IA", + "system": "Sistema" + }, + "aiBeta": "IA Beta", + "label": "Chat", + "menu": "Menu", + "newChat": "Nuova Chat", + "deleteChat": "Elimina Chat", + "deleteMessage": "Cancella messaggio", + "viewAsMermaid": "Visualizza come Mermaid", + "placeholder": { + "title": "Progettiamo il tuo diagramma", + "description": "Descrivi il diagramma che vuoi creare e noi lo genereremo per te.", + "hint": "Al momento conosciamo i diagrammi di flusso, di sequenza e di classe." + }, + "preview": "Anteprima", + "insert": "Inserisci", + "retry": "Riprova", + "errors": { + "promptTooShort": "Il prompt è troppo corto (min {{min}} caratteri)", + "promptTooLong": "Il prompt è troppo lungo (max {{max}} caratteri)", + "generationFailed": "Generazione non riuscita", + "invalidDiagram": "È stato generato un diagramma non valido :(. Puoi modificarlo manualmente, riprovare con la correzione automatica o provare un prompt diverso.", + "fixInMermaid": "Modifica Mermaid manualmente→", + "aiRepair": "Rigenera (correzione automatica) →", + "requestAborted": "Richiesta annullata", + "requestFailed": "Richiesta non riuscita", + "mermaidParseError": "Errore di sintassi Mermaid" + }, + "rateLimit": { + "messageLimit": "Hai raggiunto il tuo limite di IA sul piano gratuito. Prova Excalidraw+ per saperne di più o torna domani.", + "generalRateLimit": "Fermati, sei troppo veloce per noi! Attendi un attimo prima di riprovare.", + "messageLimitInputPlaceholder": "Hai raggiunto il limite di messaggi" + }, + "upsellBtnLabel": "Aggiorna alla Plus" }, "quickSearch": { "placeholder": "Ricerca rapida" diff --git a/packages/excalidraw/locales/ja-JP.json b/packages/excalidraw/locales/ja-JP.json index 3c0f1e79b9..7ca89cd0b6 100644 --- a/packages/excalidraw/locales/ja-JP.json +++ b/packages/excalidraw/locales/ja-JP.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "すべてのデータはブラウザにローカル保存されます。", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "代わりにExcalidraw+を開きますか?", "menuHint": "エクスポート、設定、言語..." }, @@ -612,7 +614,55 @@ "button": "挿入", "description": "現在、FlowchartSequenceClass のダイアグラムのみに対応しています。その他の種類は、Excalidraw では画像として描画されます。", "syntax": "Mermaid 構文", - "preview": "プレビュー" + "preview": "プレビュー", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/kaa.json b/packages/excalidraw/locales/kaa.json index f224d85c82..3763d9d52d 100644 --- a/packages/excalidraw/locales/kaa.json +++ b/packages/excalidraw/locales/kaa.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "Eksportlaw, sazlawlar, tiller, ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/kab-KAB.json b/packages/excalidraw/locales/kab-KAB.json index c4ce28dff2..3c2e72b255 100644 --- a/packages/excalidraw/locales/kab-KAB.json +++ b/packages/excalidraw/locales/kab-KAB.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Akk isefka-inek•inem ttwakelsen s wudem adigan deg yiminig-inek•inem.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Tebɣiḍ ad tedduḍ ɣer Excalidraw+ deg umḍiq?", "menuHint": "Asifeḍ, ismenyifen, tutlayin, ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/kk-KZ.json b/packages/excalidraw/locales/kk-KZ.json index 4bbf8e870a..eea2b982ef 100644 --- a/packages/excalidraw/locales/kk-KZ.json +++ b/packages/excalidraw/locales/kk-KZ.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/km-KH.json b/packages/excalidraw/locales/km-KH.json index 91dbf6dbb6..5a8529a7cf 100644 --- a/packages/excalidraw/locales/km-KH.json +++ b/packages/excalidraw/locales/km-KH.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "ទិន្នន័យទាំងអស់របស់អ្នក ត្រូវបានរក្សាទុកនៅក្នុង browser របស់អ្នក ។", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "តើ​អ្នក​ចង់​ទៅ Excalidraw+ ​វិញ ឬ មែន?", "menuHint": "នាំចេញ ចំណូលចិត្ត ភាសា ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/ko-KR.json b/packages/excalidraw/locales/ko-KR.json index 51bba84854..1d3ad461a4 100644 --- a/packages/excalidraw/locales/ko-KR.json +++ b/packages/excalidraw/locales/ko-KR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "모든 데이터는 브라우저에 안전하게 저장됩니다.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "대신 Excalidraw+로 이동하시겠습니까?", "menuHint": "내보내기, 설정, 언어, ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/ku-TR.json b/packages/excalidraw/locales/ku-TR.json index 2a9c939e7e..aa26ea1ca7 100644 --- a/packages/excalidraw/locales/ku-TR.json +++ b/packages/excalidraw/locales/ku-TR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "هەموو داتاکانت لە ناوخۆی وێنگەڕەکەتدا پاشەکەوت کراوە.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "ویستت بڕۆیت بۆ Excalidraw+?", "menuHint": "هەناردەکردن، ڕێکخستنەکان، زمانەکان، ..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/lt-LT.json b/packages/excalidraw/locales/lt-LT.json index dc78eafcae..2cbefa0984 100644 --- a/packages/excalidraw/locales/lt-LT.json +++ b/packages/excalidraw/locales/lt-LT.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/lv-LV.json b/packages/excalidraw/locales/lv-LV.json index 65d0b786b5..7c628e2888 100644 --- a/packages/excalidraw/locales/lv-LV.json +++ b/packages/excalidraw/locales/lv-LV.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Visi jūsu dati tiek glabāti uz vietas jūsu pārlūkā.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Vai tā vietā vēlies doties uz Excalidraw+?", "menuHint": "Eksportēšana, iestatījumi, valodas..." }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/mr-IN.json b/packages/excalidraw/locales/mr-IN.json index eb47789013..24f6b15da5 100644 --- a/packages/excalidraw/locales/mr-IN.json +++ b/packages/excalidraw/locales/mr-IN.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "तुमचा सर्व डेटा तुमच्या ब्राउझरमध्ये स्थानिक पातळीवर जतन केला जातो.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "त्याऐवजी तुम्हाला Excalidraw+ वर जायचे आहे का?", "menuHint": "निर्यात, आवड़ी-निवडी, भाषा, ..." }, @@ -612,7 +614,55 @@ "button": "शिरवा", "description": "सध्या फक्त प्रवाह चित्र (फ़्लो चार्ट) आणि क्रम चित्र (सिकवेंस ड़ायग्राम) करता येतात. बाक़ीचे चित्र प्रकार एक्सकाली चित्र पद्धति नी चित्रित होतील.", "syntax": "मर्मेड संरचना नियम", - "preview": "पूर्वावलोकन" + "preview": "पूर्वावलोकन", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "जलद शोध" diff --git a/packages/excalidraw/locales/my-MM.json b/packages/excalidraw/locales/my-MM.json index 16526bad99..d7576a1bae 100644 --- a/packages/excalidraw/locales/my-MM.json +++ b/packages/excalidraw/locales/my-MM.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/nb-NO.json b/packages/excalidraw/locales/nb-NO.json index 8dc47529ee..4a4fef3f01 100644 --- a/packages/excalidraw/locales/nb-NO.json +++ b/packages/excalidraw/locales/nb-NO.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Alle dine data lagres lokalt i din nettleser.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Ønsker du å gå til Excalidraw+ i stedet?", "menuHint": "Eksporter, innstillinger, språk, ..." }, @@ -612,7 +614,55 @@ "button": "Sett inn", "description": "Foreløpig er bare Flowchart-, Sequence- og klasse -diagrammer støttet. De andre typene vil bli gjengitt som bilde i Excalidraw.", "syntax": "Mermaid-syntaks", - "preview": "Forhåndsvisning" + "preview": "Forhåndsvisning", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Hurtigsøk" diff --git a/packages/excalidraw/locales/nl-NL.json b/packages/excalidraw/locales/nl-NL.json index 37bbfcf05d..9c7d292ae3 100644 --- a/packages/excalidraw/locales/nl-NL.json +++ b/packages/excalidraw/locales/nl-NL.json @@ -2,7 +2,7 @@ "labels": { "paste": "Plakken", "pasteAsPlaintext": "Plakken als platte tekst", - "pasteCharts": "Plak grafieken", + "pasteCharts": "Grafieken plakken", "selectAll": "Alles selecteren", "multiSelect": "Voeg element toe aan selectie", "moveCanvas": "Canvas verplaatsen", @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Al je data is lokaal opgeslagen in je browser.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Wil je in plaats daarvan naar Excalidraw+ gaan?", "menuHint": "Exporteren, voorkeuren en meer, ..." }, @@ -612,7 +614,55 @@ "button": "Invoegen", "description": "Momenteel worden alleen Flowchart-, Sequence- en Class-diagrammen ondersteund. De andere types worden als afbeelding weergegeven in Excalidraw.", "syntax": "Mermaid Syntaxis", - "preview": "Voorbeeld" + "preview": "Voorbeeld", + "label": "Mermaid", + "inputPlaceholder": "Noteer Mermaid diagram omschrijving hier..." + }, + "ttd": { + "error": "Fout!" + }, + "chat": { + "inputPlaceholder": "Begin je diagram idee hier in te typen... ({{shortcut}} voor een nieuwe lijn)", + "inputPlaceholderWithMessages": "Ga door met het verfijnen van je diagram...", + "generating": "", + "rateLimitRemaining": "{{count}} verzoeken over vandaag", + "role": { + "user": "Jij", + "assistant": "AI Assistent", + "system": "Systeem" + }, + "aiBeta": "AI Beta", + "label": "Chat", + "menu": "Menu", + "newChat": "Nieuwe Chat", + "deleteChat": "Chat verwijderen", + "deleteMessage": "Bericht verwijderen", + "viewAsMermaid": "Weergeven als Mermaid", + "placeholder": { + "title": "Laten we jouw diagram ontwerpen", + "description": "Beschrijf het diagram dat je wilt aanmaken, en we genereren het voor je.", + "hint": "Op dit moment kennen we Flowchart, Sequence, en Class diagrammen." + }, + "preview": "Voorbeeld", + "insert": "Invoegen", + "retry": "Opnieuw proberen", + "errors": { + "promptTooShort": "Vraag is te kort (min {{min}} karakters)", + "promptTooLong": "Vraag is te kort (max {{max}} karakters)", + "generationFailed": "Generatie mislukt", + "invalidDiagram": "Een ongeldige diagram gegenereerd :(. Je kunt handmatig bewerken, opnieuw proberen met automatisch repareren of een andere vraag proberen.", + "fixInMermaid": "Mermaid handmatig bewerken →", + "aiRepair": "Opnieuw genereren (automatisch repareren) →", + "requestAborted": "Verzoek gestopt", + "requestFailed": "Verzoek mislukt", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "Je hebt je AI limiet bereikt op het gratis abonnement. Probeer Excalidraw+ uit voor meer of kom morgen terug.", + "generalRateLimit": "Ho ho, rustig aan, je gaat te snel voor ons! Wacht even voor je het opnieuw probeert.", + "messageLimitInputPlaceholder": "Je hebt je berichtenlimiet bereikt" + }, + "upsellBtnLabel": "Upgrade naar Plus" }, "quickSearch": { "placeholder": "Snel Zoeken" diff --git a/packages/excalidraw/locales/nn-NO.json b/packages/excalidraw/locales/nn-NO.json index d57141f93d..2cca629bf4 100644 --- a/packages/excalidraw/locales/nn-NO.json +++ b/packages/excalidraw/locales/nn-NO.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/oc-FR.json b/packages/excalidraw/locales/oc-FR.json index db2f43a90d..573b77c1f9 100644 --- a/packages/excalidraw/locales/oc-FR.json +++ b/packages/excalidraw/locales/oc-FR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Totas las donadas son enregistradas dins vòstre navegador.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Voliatz puslèu utilizar Excalidraw+ a la plaça ?", "menuHint": "Exportar, preferéncias, lengas, ..." }, @@ -612,7 +614,55 @@ "button": "Inserir", "description": "Actualament, sonque los diagramas logics, de sequéncia e de classa son preses en carga. Los autres tipes seràn afichats coma imatge dins Excalidraw.", "syntax": "Sintaxi Mermaid", - "preview": "Apercebut" + "preview": "Apercebut", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Recèrca rapida" diff --git a/packages/excalidraw/locales/pa-IN.json b/packages/excalidraw/locales/pa-IN.json index bf20e81cf9..6cceb0f548 100644 --- a/packages/excalidraw/locales/pa-IN.json +++ b/packages/excalidraw/locales/pa-IN.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "ਤੁਹਾਡਾ ਸਾਰਾ ਡਾਟਾ ਤੁਹਾਡੇ ਲੋਕਲ ਬਰਾਉਜ਼ਰ ਵਿੱਚ ਸਾਂਭਿਆ ਹੋਇਆ ਹੈ।", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "ਨਿਰਯਾਤ, ਤਰਜੀਹਾਂ, ਭਾਸ਼ਾਵਾਂ, …" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/percentages.json b/packages/excalidraw/locales/percentages.json index 8b961563d8..61fffadd56 100644 --- a/packages/excalidraw/locales/percentages.json +++ b/packages/excalidraw/locales/percentages.json @@ -1,59 +1,59 @@ { - "ar-SA": 91, - "az-AZ": 27, - "bg-BG": 70, - "bn-BD": 42, - "bn-IN": 42, - "ca-ES": 91, - "cs-CZ": 82, - "da-DK": 30, - "de-CH": 91, - "de-DE": 92, - "el-GR": 86, + "ar-SA": 84, + "az-AZ": 25, + "bg-BG": 65, + "bn-BD": 39, + "bn-IN": 39, + "ca-ES": 84, + "cs-CZ": 76, + "da-DK": 28, + "de-CH": 85, + "de-DE": 85, + "el-GR": 80, "en": 100, - "es-ES": 91, - "eu-ES": 78, - "fa-IR": 91, - "fi-FI": 67, - "fr-FR": 99, - "gl-ES": 67, - "he-IL": 91, - "hi-IN": 91, - "hu-HU": 53, - "id-ID": 90, - "it-IT": 100, - "ja-JP": 87, - "kaa": 23, - "kab-KAB": 60, - "kk-KZ": 14, - "km-KH": 60, - "ko-KR": 80, - "ku-TR": 64, - "lt-LT": 37, - "lv-LV": 56, - "mr-IN": 91, - "my-MM": 27, - "nb-NO": 91, - "nl-NL": 100, - "nn-NO": 48, - "oc-FR": 84, - "pa-IN": 58, - "pl-PL": 99, - "pt-BR": 90, - "pt-PT": 91, + "es-ES": 85, + "eu-ES": 72, + "fa-IR": 84, + "fi-FI": 62, + "fr-FR": 92, + "gl-ES": 63, + "he-IL": 84, + "hi-IN": 86, + "hu-HU": 67, + "id-ID": 84, + "it-IT": 99, + "ja-JP": 81, + "kaa": 22, + "kab-KAB": 56, + "kk-KZ": 13, + "km-KH": 56, + "ko-KR": 75, + "ku-TR": 59, + "lt-LT": 35, + "lv-LV": 53, + "mr-IN": 84, + "my-MM": 25, + "nb-NO": 84, + "nl-NL": 99, + "nn-NO": 45, + "oc-FR": 79, + "pa-IN": 54, + "pl-PL": 93, + "pt-BR": 84, + "pt-PT": 84, "ro-RO": 100, "ru-RU": 99, - "si-LK": 75, - "sk-SK": 100, - "sl-SI": 91, - "sv-SE": 91, - "ta-IN": 86, - "th-TH": 63, - "tr-TR": 94, - "uk-UA": 90, + "si-LK": 70, + "sk-SK": 93, + "sl-SI": 84, + "sv-SE": 85, + "ta-IN": 81, + "th-TH": 59, + "tr-TR": 88, + "uk-UA": 84, "uz-UZ": 0, - "vi-VN": 75, - "zh-CN": 99, - "zh-HK": 17, - "zh-TW": 91 + "vi-VN": 70, + "zh-CN": 93, + "zh-HK": 16, + "zh-TW": 84 } diff --git a/packages/excalidraw/locales/pl-PL.json b/packages/excalidraw/locales/pl-PL.json index 8646ebce21..c5ff6777fb 100644 --- a/packages/excalidraw/locales/pl-PL.json +++ b/packages/excalidraw/locales/pl-PL.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Wszystkie dane są zapisywane lokalnie w przeglądarce.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Czy zamiast tego chcesz przejść do Excalidraw+?", "menuHint": "Eksportuj, preferencje, języki..." }, @@ -612,7 +614,55 @@ "button": "Wstaw", "description": "Obecnie wspierane są jedynie proste grafy, sekwencje i diagramy klas. Pozostałe typy będą wyświetlane jako obrazy w Excalidraw.", "syntax": "Składnia diagramów Mermaid", - "preview": "Podgląd" + "preview": "Podgląd", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Szybkie wyszukiwanie" diff --git a/packages/excalidraw/locales/pt-BR.json b/packages/excalidraw/locales/pt-BR.json index f063bb4b24..934a4d3307 100644 --- a/packages/excalidraw/locales/pt-BR.json +++ b/packages/excalidraw/locales/pt-BR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Todos os dados são salvos localmente no seu navegador.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Você queria ir para o Excalidraw+ em vez disso?", "menuHint": "Exportar, preferências, idiomas..." }, @@ -612,7 +614,55 @@ "button": "Inserir", "description": "Atualmente apenas os diagramasFlowchartSequência, e Classsão suportados. Os outros tipos serão renderizados como uma imagem no Excalidraw.", "syntax": "Sintaxe em Mermaid", - "preview": "Visualizar" + "preview": "Visualizar", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Busca rápida" diff --git a/packages/excalidraw/locales/pt-PT.json b/packages/excalidraw/locales/pt-PT.json index 09d2e530fe..ec9c3d1bbc 100644 --- a/packages/excalidraw/locales/pt-PT.json +++ b/packages/excalidraw/locales/pt-PT.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Todos os seus dados são guardados no seu navegador local.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Queria antes ir para o Excalidraw+?", "menuHint": "Exportar, preferências, idiomas..." }, @@ -612,7 +614,55 @@ "button": "Inserir", "description": "Atualmente apenas são suportados diagramas fluxo, sequência, e classe. Os outros tipos serão renderizados como imagem no Excalidraw.", "syntax": "Sintaxe Mermaid", - "preview": "Pré-visualizar" + "preview": "Pré-visualizar", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Pesquisa rápida" diff --git a/packages/excalidraw/locales/ro-RO.json b/packages/excalidraw/locales/ro-RO.json index 177c563b2a..43fee72900 100644 --- a/packages/excalidraw/locales/ro-RO.json +++ b/packages/excalidraw/locales/ro-RO.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Toate datele tale sunt salvate local în navigatorul tău.", + "center_heading": "Desenele tale sunt salvate în spațiul de stocare al navigatorului tău.", + "center_heading_line2": "Spațiul de stocare al navigatorului poate fi șters în mod neașteptat.", + "center_heading_line3": "Salvează-ți periodic munca într-un fișier pentru a evita pierderea acesteia.", "center_heading_plus": "Ai vrut să mergi în schimb la Excalidraw+?", "menuHint": "Exportare, preferințe, limbi, ..." }, @@ -612,7 +614,55 @@ "button": "Introducere", "description": "În prezent, numai Organigramele, Diagramele de secvență și Diagramele de clasă sunt acceptate. Celelalte tipuri vor fi redate ca imagine în Excalidraw.", "syntax": "Sintaxă Mermaid", - "preview": "Previzualizare" + "preview": "Previzualizare", + "label": "Mermaid", + "inputPlaceholder": "Scrie definiţia diagramei Mermaid aici..." + }, + "ttd": { + "error": "Eroare!" + }, + "chat": { + "inputPlaceholder": "Începe să îți introduci ideea de diagramă aici... ({{shortcut}} pentru linia nouă)", + "inputPlaceholderWithMessages": "Continuă rafinarea diagramei...", + "generating": "Se generează...", + "rateLimitRemaining": "Cereri rămase astăzi: {{count}}", + "role": { + "user": "Tu", + "assistant": "Asistent IA", + "system": "Sistem" + }, + "aiBeta": "Beta IA", + "label": "Conversație", + "menu": "Meniu", + "newChat": "Conversație nouă", + "deleteChat": "Ștergere conversație", + "deleteMessage": "Ștergere mesaj", + "viewAsMermaid": "Vizualizare ca Mermaid", + "placeholder": { + "title": "Hai să-ți proiectăm diagrama", + "description": "Descrie diagrama pe care vrei să o creezi și o vom genera pentru tine.", + "hint": "În acest moment cunoaștem organigrame, diagrame de secvență și diagrame de clasă." + }, + "preview": "Previzualizare", + "insert": "Introducere", + "retry": "Reîncercare", + "errors": { + "promptTooShort": "Solicitarea este prea scurtă (min. {{min}} caractere)", + "promptTooLong": "Solicitarea este prea lungă (max. {{max}} caractere)", + "generationFailed": "Generare nereușită", + "invalidDiagram": "A generat o diagramă nevalidă :(. Poți edita manual, reîncerca cu funcția de fixare automată sau încerca o altă solicitare.", + "fixInMermaid": "Editare Mermaid manual →", + "aiRepair": "Regenerare (fixare automată) →", + "requestAborted": "Cerere anulată", + "requestFailed": "Cerere nereușită", + "mermaidParseError": "Eroare de sintaxă Mermaid" + }, + "rateLimit": { + "messageLimit": "Ai atins limita IA la planul gratuit. Încearcă Excalidraw+ pentru mai multe sau revino mâine.", + "generalRateLimit": "Stai puțin, te grăbești! Așteaptă un moment înainte de a reîncerca.", + "messageLimitInputPlaceholder": "Ai atins limita de mesaje" + }, + "upsellBtnLabel": "Actualizare la Plus" }, "quickSearch": { "placeholder": "Căutare rapidă" diff --git a/packages/excalidraw/locales/ru-RU.json b/packages/excalidraw/locales/ru-RU.json index 17959f4b5f..e2fcb48e2c 100644 --- a/packages/excalidraw/locales/ru-RU.json +++ b/packages/excalidraw/locales/ru-RU.json @@ -38,7 +38,7 @@ "round": "Скруглённые", "arrowheads": "Стрелка", "arrowhead_none": "Нет", - "arrowhead_arrow": "Cтрелка", + "arrowhead_arrow": "Стрелка", "arrowhead_bar": "Черта", "arrowhead_circle": "Круг", "arrowhead_circle_outline": "Круг (контур)", @@ -341,7 +341,7 @@ "canvasPanning": "Чтобы переместить холст, удерживайте {{shortcut_1}} или {{shortcut_2}} во время перетаскивания или используйте инструмент «Рука»", "linearElement": "Нажмите, чтобы начать несколько точек, перетащите для одной линии", "arrowTool": "Нажмите, чтобы начать рисование нескольких точек, перетащите для одной линии. Нажмите {{shortcut}} снова, чтобы изменить тип стрелки.", - "arrowBindModifiers": "", + "arrowBindModifiers": "Удерживайте {{shortcut_1}} , чтобы отключить привязку, или {{shortcut_2}} для привязки в фиксированной точке", "freeDraw": "Нажмите и перетаскивайте, отпустите по завершении", "text": "Совет: при выбранном инструменте выделения дважды щёлкните в любом месте, чтобы добавить текст", "embeddable": "Перетащите для встраивания веб-сайта", @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Все ваши данные сохраняются локально в вашем браузере.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Хотите перейти на Excalidraw+?", "menuHint": "Экспорт, настройки, языки, ..." }, @@ -612,7 +614,55 @@ "button": "Вставить", "description": "В настоящее время поддерживаются только блок-схемы, диаграммы последовательности и диаграммы классов. Другие типы будут отображаться в виде изображения в Excalidraw.", "syntax": "Синтаксис Mermaid", - "preview": "Предпросмотр" + "preview": "Предпросмотр", + "label": "Mermaid", + "inputPlaceholder": "Напишите здесь определение диаграммы Mermaid..." + }, + "ttd": { + "error": "Ошибка!" + }, + "chat": { + "inputPlaceholder": "Начните вводить сюда идею диаграммы... ({{shortcut}} для новой строки)", + "inputPlaceholderWithMessages": "Продолжайте доработку диаграммы...", + "generating": "", + "rateLimitRemaining": "Осталось запросов сегодня: {{count}}", + "role": { + "user": "Вы", + "assistant": "ИИ-помощник", + "system": "Система" + }, + "aiBeta": "ИИ-бета", + "label": "Чат", + "menu": "Меню", + "newChat": "Новый чат", + "deleteChat": "Удалить чат", + "deleteMessage": "Удалить сообщение", + "viewAsMermaid": "Просмотреть как Mermaid", + "placeholder": { + "title": "Давайте создадим вашу диаграмму", + "description": "Опишите диаграмму, которую вы хотите создать, и мы сгенерируем её для вас.", + "hint": "На данный момент мы знаем диаграммы блок-схемы, последовательности и классов." + }, + "preview": "Предпросмотр", + "insert": "Вставить", + "retry": "Повторить", + "errors": { + "promptTooShort": "Подсказка слишком короткая (мин. символов: {{min}})", + "promptTooLong": "Подсказка слишком длинная (макс. символов: {{max}})", + "generationFailed": "Генерация не удалась", + "invalidDiagram": "Сгенерирована неверная диаграмма :(. Вы можете редактировать вручную, повторите с автоисправлением или попробуйте другую подсказку.", + "fixInMermaid": "Изменить Mermaid вручную →", + "aiRepair": "Перегенерировать (автоисправление) →", + "requestAborted": "Запрос отменён", + "requestFailed": "Запрос не удался", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "Вы достигли лимита ИИ на бесплатном тарифном плане. Попробуйте Excalidraw+ или вернитесь завтра.", + "generalRateLimit": "Подождите, вы слишком быстры для нас! Пожалуйста, подождите немного, прежде чем повторить попытку.", + "messageLimitInputPlaceholder": "Вы достигли лимита сообщений" + }, + "upsellBtnLabel": "Перейти на Plus" }, "quickSearch": { "placeholder": "Быстрый поиск" diff --git a/packages/excalidraw/locales/si-LK.json b/packages/excalidraw/locales/si-LK.json index 51dfaf5bc1..5d3ef161cd 100644 --- a/packages/excalidraw/locales/si-LK.json +++ b/packages/excalidraw/locales/si-LK.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "අයදුම් මධ්‍ය ශීර්ෂය", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "අයදුම් මධ්‍ය ශීර්ෂය Plus", "menuHint": "මෙනු ඉඟිය" }, @@ -612,7 +614,55 @@ "button": "Mermaid බොත්තම", "description": "Mermaid විස්තර", "syntax": "Mermaid වාක්‍ය ඛණ්ඩය", - "preview": "Mermaid පූර්වදර්ශනය" + "preview": "Mermaid පූර්වදර්ශනය", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/sk-SK.json b/packages/excalidraw/locales/sk-SK.json index c538cf222b..b73f238a23 100644 --- a/packages/excalidraw/locales/sk-SK.json +++ b/packages/excalidraw/locales/sk-SK.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Všetky vaše dáta sú uložené lokálne vo vašom prehliadači.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Chceli ste namiesto toho prejsť do Excalidraw+?", "menuHint": "Exportovanie, nastavenia, jazyky, ..." }, @@ -612,7 +614,55 @@ "button": "Vložiť", "description": "Aktuálne sú podporované iba vývojové diagramy, sekvenčné diagramy a diagramy tried. Ostatné typy budú v Excalidraw vykreslené ako obrázky.", "syntax": "Mermaid syntax", - "preview": "Ukážka" + "preview": "Ukážka", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Rýchle vyhľadávanie" diff --git a/packages/excalidraw/locales/sl-SI.json b/packages/excalidraw/locales/sl-SI.json index 27bfab060f..ccb9a6eed6 100644 --- a/packages/excalidraw/locales/sl-SI.json +++ b/packages/excalidraw/locales/sl-SI.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Vsi vaši podatki so shranjeni lokalno v vašem brskalniku.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Ste namesto tega želeli odpreti Excalidraw+?", "menuHint": "Izvoz, nastavitve, jeziki, ..." }, @@ -612,7 +614,55 @@ "button": "Vstavi", "description": "Trenutno so podprti samo diagrami poteka, diagrami zaporedij in Razredni diagrami. Druge vrste bodo upodobljene kot slike v Excalidraw.", "syntax": "Sintaksa Mermaid", - "preview": "Predogled" + "preview": "Predogled", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Hitro iskanje" diff --git a/packages/excalidraw/locales/sv-SE.json b/packages/excalidraw/locales/sv-SE.json index 3c02bd11e0..7834769555 100644 --- a/packages/excalidraw/locales/sv-SE.json +++ b/packages/excalidraw/locales/sv-SE.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "All data sparas lokalt i din webbläsare.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Ville du gå till Excalidraw+ istället?", "menuHint": "Exportera, inställningar, språk, ..." }, @@ -612,7 +614,55 @@ "button": "Infoga", "description": "För närvarande stöds endast Flödesdiagram, Sekvensdiagram och Klassdiagram. De andra typerna kommer att återges som bild i Excalidraw.", "syntax": "Mermaid-syntax", - "preview": "Förhandsgranska" + "preview": "Förhandsgranska", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Snabbsök" diff --git a/packages/excalidraw/locales/ta-IN.json b/packages/excalidraw/locales/ta-IN.json index 06e5871a2a..d1d4db0965 100644 --- a/packages/excalidraw/locales/ta-IN.json +++ b/packages/excalidraw/locales/ta-IN.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "நீங்கள் Excalidraw+க்கு செல்ல விரும்புகிறீர்களா?", "menuHint": "விருப்பத்தேர்வுகள், மொழிகள் போன்றனவை ஏற்றுமதிசெய்..." }, @@ -612,7 +614,55 @@ "button": "புகுத்து", "description": "", "syntax": "மெர்மெய்டு தொடரியல்", - "preview": "முன்னோட்டம்" + "preview": "முன்னோட்டம்", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "விரைவு தேடல்" diff --git a/packages/excalidraw/locales/th-TH.json b/packages/excalidraw/locales/th-TH.json index 8dbd1ff778..9de713ce02 100644 --- a/packages/excalidraw/locales/th-TH.json +++ b/packages/excalidraw/locales/th-TH.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "แทรก", "description": "", "syntax": "", - "preview": "ดูตัวอย่าง" + "preview": "ดูตัวอย่าง", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "ค้นหาด่วน" diff --git a/packages/excalidraw/locales/tr-TR.json b/packages/excalidraw/locales/tr-TR.json index a0046c3b6c..ef2dbd108c 100644 --- a/packages/excalidraw/locales/tr-TR.json +++ b/packages/excalidraw/locales/tr-TR.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Tüm verileriniz tarayıcınızda yerel olarak kaydedilir.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Ecalidraw+'a mı gitmek istediniz?", "menuHint": "Dışa aktar, seçenekler, diller, ..." }, @@ -612,7 +614,55 @@ "button": "Ekle", "description": "Şu anda yalnızca Akış şeması, Dizi, ve Sınıf Diyagramları deskteklenmektedir. Diğer türler, Excalidraw'da görsel olarak çizilecektir.", "syntax": "Mermaid Sözdizimi", - "preview": "Önizleme" + "preview": "Önizleme", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Hızlı arama" diff --git a/packages/excalidraw/locales/uk-UA.json b/packages/excalidraw/locales/uk-UA.json index a5240ad82f..023a9188d6 100644 --- a/packages/excalidraw/locales/uk-UA.json +++ b/packages/excalidraw/locales/uk-UA.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "Всі ваші дані збережено локально у Вашому браузері.", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "Чи бажаєте перейти до Excalidraw+?", "menuHint": "Експорт, налаштування, мови, ..." }, @@ -612,7 +614,55 @@ "button": "Вставити", "description": "Наразі підтримується тільки блок-схемидіаграми послідовностей та діаграми класів. Інші типи будуть відображатися як зображення в Excalidraw.", "syntax": "Синтаксис Mermaid", - "preview": "Попередній перегляд" + "preview": "Попередній перегляд", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "Швидкий пошук" diff --git a/packages/excalidraw/locales/uz-UZ.json b/packages/excalidraw/locales/uz-UZ.json index 9b1095f669..f06fd97c1f 100644 --- a/packages/excalidraw/locales/uz-UZ.json +++ b/packages/excalidraw/locales/uz-UZ.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/vi-VN.json b/packages/excalidraw/locales/vi-VN.json index e83afbf328..7f62efb2b4 100644 --- a/packages/excalidraw/locales/vi-VN.json +++ b/packages/excalidraw/locales/vi-VN.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/zh-CN.json b/packages/excalidraw/locales/zh-CN.json index 4f4ff616a8..1658015e9d 100644 --- a/packages/excalidraw/locales/zh-CN.json +++ b/packages/excalidraw/locales/zh-CN.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "您的所有数据都储存在浏览器本地。", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "是否前往 Excalidraw+ ?", "menuHint": "导出、首选项、语言……" }, @@ -612,7 +614,55 @@ "button": "插入", "description": "目前仅支持流程图序列图类图。其他类型在 Excalidraw 中将以图像呈现。", "syntax": "Mermaid 语法", - "preview": "预览" + "preview": "预览", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "快速搜索" diff --git a/packages/excalidraw/locales/zh-HK.json b/packages/excalidraw/locales/zh-HK.json index 048be6c8d5..880c75a3a8 100644 --- a/packages/excalidraw/locales/zh-HK.json +++ b/packages/excalidraw/locales/zh-HK.json @@ -558,6 +558,8 @@ "welcomeScreen": { "app": { "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "", "menuHint": "" }, @@ -612,7 +614,55 @@ "button": "", "description": "", "syntax": "", - "preview": "" + "preview": "", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "" diff --git a/packages/excalidraw/locales/zh-TW.json b/packages/excalidraw/locales/zh-TW.json index 036459bc49..94cdcbf893 100644 --- a/packages/excalidraw/locales/zh-TW.json +++ b/packages/excalidraw/locales/zh-TW.json @@ -557,7 +557,9 @@ }, "welcomeScreen": { "app": { - "center_heading": "所有資料皆已在瀏覽器中儲存於本機", + "center_heading": "", + "center_heading_line2": "", + "center_heading_line3": "", "center_heading_plus": "您是否是要前往 Excalidraw+ ?", "menuHint": "輸出、偏好設定、語言..." }, @@ -612,7 +614,55 @@ "button": "插入", "description": "目前僅支援 FlowchartSequenceClass 圖表。其餘檔案類型在 Excalidraw 將會以圖像呈現。", "syntax": "Mermaid 語法", - "preview": "預覽" + "preview": "預覽", + "label": "", + "inputPlaceholder": "" + }, + "ttd": { + "error": "" + }, + "chat": { + "inputPlaceholder": "", + "inputPlaceholderWithMessages": "", + "generating": "", + "rateLimitRemaining": "", + "role": { + "user": "", + "assistant": "", + "system": "" + }, + "aiBeta": "", + "label": "", + "menu": "", + "newChat": "", + "deleteChat": "", + "deleteMessage": "", + "viewAsMermaid": "", + "placeholder": { + "title": "", + "description": "", + "hint": "" + }, + "preview": "", + "insert": "", + "retry": "", + "errors": { + "promptTooShort": "", + "promptTooLong": "", + "generationFailed": "", + "invalidDiagram": "", + "fixInMermaid": "", + "aiRepair": "", + "requestAborted": "", + "requestFailed": "", + "mermaidParseError": "" + }, + "rateLimit": { + "messageLimit": "", + "generalRateLimit": "", + "messageLimitInputPlaceholder": "" + }, + "upsellBtnLabel": "" }, "quickSearch": { "placeholder": "快速搜尋" diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 54560a35e9..0dc9bf618a 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -62,8 +62,7 @@ "@excalidraw/math": "0.18.0", "@excalidraw/mermaid-to-excalidraw": "2.0.0-rfc3", "@excalidraw/random-username": "1.1.0", - "@radix-ui/react-popover": "1.1.6", - "@radix-ui/react-tabs": "1.1.3", + "radix-ui": "1.4.3", "browser-fs-access": "0.29.1", "canvas-roundrect-polyfill": "0.0.1", "clsx": "1.1.1", diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index 32e26cebb7..45f32938fd 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -5,6 +5,8 @@ import { type GlobalPoint, type LocalPoint, type Radians, + bezierEquation, + pointRotateRads, } from "@excalidraw/math"; import { @@ -21,10 +23,15 @@ import { deconstructDiamondElement, deconstructRectanguloidElement, elementCenterPoint, + getDiamondBaseCorners, + FOCUS_POINT_SIZE, getOmitSidesForEditorInterface, getTransformHandles, getTransformHandlesFromCoords, hasBoundingBox, + hitElementItself, + isArrowElement, + isBindableElement, isElbowArrow, isFrameLikeElement, isImageElement, @@ -32,6 +39,12 @@ import { isLineElement, isTextElement, LinearElementEditor, + headingForPoint, + compareHeading, + HEADING_RIGHT, + HEADING_DOWN, + HEADING_LEFT, + HEADING_UP, } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element"; @@ -44,6 +57,10 @@ import { } from "@excalidraw/element"; import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element"; +import { + getGlobalFixedPointForBindableElement, + isFocusPointVisible, +} from "@excalidraw/element"; import type { TransformHandles, @@ -52,6 +69,7 @@ import type { import type { ElementsMap, + ExcalidrawArrowElement, ExcalidrawBindableElement, ExcalidrawElement, ExcalidrawFrameLikeElement, @@ -71,11 +89,6 @@ import { SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - type AppClassProperties, - type InteractiveCanvasAppState, -} from "../types"; - import { getClientColor, renderRemoteCursors } from "../clients"; import { @@ -85,6 +98,11 @@ import { strokeRectWithRotation_simple, } from "./helpers"; +import type { + AppState, + AppClassProperties, + InteractiveCanvasAppState, +} from "../types"; import type { InteractiveCanvasRenderConfig, InteractiveSceneRenderConfig, @@ -123,6 +141,9 @@ const renderLinearElementPointHighlight = ( ) { return; } + if (appState.selectedLinearElement?.isDragging) { + return; + } const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { @@ -156,6 +177,19 @@ const highlightPoint = ( ); }; +const renderFocusPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + focusPoint: GlobalPoint, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(focusPoint, context, appState); + + context.restore(); +}; + const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -188,11 +222,14 @@ const renderSingleLinearPoint = ( const renderBindingHighlightForBindableElement_simple = ( context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: NonNullable, elementsMap: ElementsMap, appState: InteractiveCanvasAppState, + pointerCoords: GlobalPoint | null, ) => { - const enclosingFrame = element.frameId && elementsMap.get(element.frameId); + const enclosingFrame = + suggestedBinding.element.frameId && + elementsMap.get(suggestedBinding.element.frameId); if (enclosingFrame && isFrameLikeElement(enclosingFrame)) { context.translate(enclosingFrame.x, enclosingFrame.y); @@ -215,12 +252,12 @@ const renderBindingHighlightForBindableElement_simple = ( context.translate(-enclosingFrame.x, -enclosingFrame.y); } - switch (element.type) { + switch (suggestedBinding.element.type) { case "magicframe": case "frame": context.save(); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.strokeStyle = @@ -233,14 +270,19 @@ const renderBindingHighlightForBindableElement_simple = ( context.roundRect( 0, 0, - element.width, - element.height, + suggestedBinding.element.width, + suggestedBinding.element.height, FRAME_STYLE.radius / appState.zoom.value, ); context.stroke(); context.closePath(); } else { - context.strokeRect(0, 0, element.width, element.height); + context.strokeRect( + 0, + 0, + suggestedBinding.element.width, + suggestedBinding.element.height, + ); } context.restore(); @@ -248,30 +290,30 @@ const renderBindingHighlightForBindableElement_simple = ( default: context.save(); - const center = elementCenterPoint(element, elementsMap); + const center = elementCenterPoint(suggestedBinding.element, elementsMap); context.translate(center[0], center[1]); - context.rotate(element.angle as Radians); + context.rotate(suggestedBinding.element.angle as Radians); context.translate(-center[0], -center[1]); - context.translate(element.x, element.y); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); context.lineWidth = - clamp(1.75, element.strokeWidth, 4) / + clamp(1.75, suggestedBinding.element.strokeWidth, 4) / Math.max(0.25, appState.zoom.value); context.strokeStyle = appState.theme === THEME.DARK ? `rgba(3, 93, 161, 1)` : `rgba(106, 189, 252, 1)`; - switch (element.type) { + switch (suggestedBinding.element.type) { case "ellipse": context.beginPath(); context.ellipse( - element.width / 2, - element.height / 2, - element.width / 2, - element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, + suggestedBinding.element.width / 2, + suggestedBinding.element.height / 2, 0, 0, 2 * Math.PI, @@ -281,18 +323,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; case "diamond": { - const [segments, curves] = deconstructDiamondElement(element); + const [segments, curves] = deconstructDiamondElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -301,14 +345,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -317,18 +364,20 @@ const renderBindingHighlightForBindableElement_simple = ( break; default: { - const [segments, curves] = deconstructRectanguloidElement(element); + const [segments, curves] = deconstructRectanguloidElement( + suggestedBinding.element, + ); // Draw each line segment individually segments.forEach((segment) => { context.beginPath(); context.moveTo( - segment[0][0] - element.x, - segment[0][1] - element.y, + segment[0][0] - suggestedBinding.element.x, + segment[0][1] - suggestedBinding.element.y, ); context.lineTo( - segment[1][0] - element.x, - segment[1][1] - element.y, + segment[1][0] - suggestedBinding.element.x, + segment[1][1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -337,14 +386,17 @@ const renderBindingHighlightForBindableElement_simple = ( curves.forEach((curve) => { const [start, control1, control2, end] = curve; context.beginPath(); - context.moveTo(start[0] - element.x, start[1] - element.y); + context.moveTo( + start[0] - suggestedBinding.element.x, + start[1] - suggestedBinding.element.y, + ); context.bezierCurveTo( - control1[0] - element.x, - control1[1] - element.y, - control2[0] - element.x, - control2[1] - element.y, - end[0] - element.x, - end[1] - element.y, + control1[0] - suggestedBinding.element.x, + control1[1] - suggestedBinding.element.y, + control2[0] - suggestedBinding.element.x, + control2[1] - suggestedBinding.element.y, + end[0] - suggestedBinding.element.x, + end[1] - suggestedBinding.element.y, ); context.stroke(); }); @@ -357,6 +409,133 @@ const renderBindingHighlightForBindableElement_simple = ( break; } + + if ( + isFrameLikeElement(suggestedBinding.element) || + isBindableElement(suggestedBinding.element) + ) { + // Draw midpoint indicators + const linearElement = appState.selectedLinearElement; + const arrow = + linearElement?.elementId && + LinearElementEditor.getElement(linearElement?.elementId, elementsMap); + const insideBindable = + pointerCoords && + arrow && + hitElementItself({ + point: pointerCoords, + element: suggestedBinding.element, + elementsMap, + threshold: 0, + overrideShouldTestInside: true, + }); + + if (!insideBindable || isElbowArrow(arrow)) { + context.save(); + context.translate(suggestedBinding.element.x, suggestedBinding.element.y); + + const midpointRadius = 5 / appState.zoom.value; + const center = elementCenterPoint(suggestedBinding.element, elementsMap); + + let midpoints: LocalPoint[]; + if (suggestedBinding.element.type === "diamond") { + const center = elementCenterPoint( + suggestedBinding.element, + elementsMap, + ); + midpoints = getDiamondBaseCorners(suggestedBinding.element).map( + (curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads( + point, + center, + suggestedBinding.element.angle, + ); + + return pointFrom( + rotatedPoint[0] - suggestedBinding.element.x, + rotatedPoint[1] - suggestedBinding.element.y, + ); + }, + ); + } else { + const basePoints = [ + { + x: suggestedBinding.element.width, + y: suggestedBinding.element.height / 2, + }, // RIGHT + { + x: suggestedBinding.element.width / 2, + y: suggestedBinding.element.height, + }, // BOTTOM + { x: 0, y: suggestedBinding.element.height / 2 }, // LEFT + { x: suggestedBinding.element.width / 2, y: 0 }, // TOP + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + suggestedBinding.element.x, + point.y + suggestedBinding.element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + suggestedBinding.element.angle, + ); + return pointFrom( + rotatedPoint[0] - suggestedBinding.element.x, + rotatedPoint[1] - suggestedBinding.element.y, + ); + }); + } + const highlightedPoint = + suggestedBinding.midPoint && + pointFrom( + suggestedBinding.midPoint[0] - suggestedBinding.element.x, + suggestedBinding.midPoint[1] - suggestedBinding.element.y, + ); + + const target = [HEADING_RIGHT, HEADING_DOWN, HEADING_LEFT, HEADING_UP]; + midpoints.forEach((midpoint, idx) => { + const isHighlighted = + highlightedPoint && + compareHeading( + headingForPoint( + pointRotateRads( + pointFrom( + highlightedPoint[0] + suggestedBinding.element.x, + highlightedPoint[1] + suggestedBinding.element.y, + ), + center, + suggestedBinding.element.angle as Radians, + ), + center, + ), + target[idx], + ); + + if (!isHighlighted) { + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(0, 0, 0, 0.5)` + : `rgba(65, 65, 65, 0.4)`; + context.beginPath(); + context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); + context.fill(); + } else { + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, 1)` + : `rgba(106, 189, 252, 1)`; + + context.beginPath(); + context.arc(midpoint[0], midpoint[1], midpointRadius, 0, 2 * Math.PI); + context.fill(); + } + }); + + context.restore(); + } + } }; const renderBindingHighlightForBindableElement_complex = ( @@ -610,6 +789,78 @@ const renderBindingHighlightForBindableElement_complex = ( context.fill(); context.restore(); + + // Draw midpoint indicators + context.save(); + context.translate( + element.x + appState.scrollX, + element.y + appState.scrollY, + ); + + const midpointRadius = 5 / appState.zoom.value; + const cutoutPadding = 5 / appState.zoom.value; + const cutoutRadius = midpointRadius + cutoutPadding; + + let midpoints; + if (element.type === "diamond") { + const [, curves] = deconstructDiamondElement(element); + const center = elementCenterPoint(element, allElementsMap); + + midpoints = curves.map((curve) => { + const point = bezierEquation(curve, 0.5); + const rotatedPoint = pointRotateRads(point, center, element.angle); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } else { + const center = elementCenterPoint(element, allElementsMap); + const basePoints = [ + { x: element.width / 2, y: 0 }, // TOP + { x: element.width, y: element.height / 2 }, // RIGHT + { x: element.width / 2, y: element.height }, // BOTTOM + { x: 0, y: element.height / 2 }, // LEFT + ]; + midpoints = basePoints.map((point) => { + const globalPoint = pointFrom( + point.x + element.x, + point.y + element.y, + ); + const rotatedPoint = pointRotateRads( + globalPoint, + center, + element.angle, + ); + return { + x: rotatedPoint[0] - element.x, + y: rotatedPoint[1] - element.y, + }; + }); + } + + // Clear cutouts around midpoints + midpoints.forEach((midpoint) => { + context.clearRect( + midpoint.x - cutoutRadius, + midpoint.y - cutoutRadius, + cutoutRadius * 2, + cutoutRadius * 2, + ); + }); + + context.fillStyle = + appState.theme === THEME.DARK + ? `rgba(3, 93, 161, ${opacity})` + : `rgba(106, 189, 252, ${opacity})`; + + midpoints.forEach((midpoint) => { + context.beginPath(); + context.arc(midpoint.x, midpoint.y, midpointRadius, 0, 2 * Math.PI); + context.fill(); + }); + + context.restore(); } return { @@ -620,17 +871,21 @@ const renderBindingHighlightForBindableElement_complex = ( const renderBindingHighlightForBindableElement = ( app: AppClassProperties, context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, + suggestedBinding: AppState["suggestedBinding"], allElementsMap: NonDeletedSceneElementsMap, appState: InteractiveCanvasAppState, deltaTime: number, state?: { runtime: number }, ) => { + if (suggestedBinding === null) { + return; + } + if (getFeatureFlag("COMPLEX_BINDINGS")) { return renderBindingHighlightForBindableElement_complex( app, context, - element, + suggestedBinding.element, allElementsMap, appState, deltaTime, @@ -640,11 +895,18 @@ const renderBindingHighlightForBindableElement = ( context.save(); context.translate(appState.scrollX, appState.scrollY); + const pointerCoords = app.lastPointerMoveCoords + ? pointFrom( + app.lastPointerMoveCoords.x, + app.lastPointerMoveCoords.y, + ) + : null; renderBindingHighlightForBindableElement_simple( context, - element, + suggestedBinding, allElementsMap, appState, + pointerCoords, ); context.restore(); }; @@ -920,6 +1182,146 @@ const renderLinearPointHandles = ( context.restore(); }; +const renderFocusPointConnectionLine = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + fromPoint: GlobalPoint, + toPoint: GlobalPoint, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + context.strokeStyle = "rgba(134, 131, 226, 0.6)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([4 / appState.zoom.value, 4 / appState.zoom.value]); + + context.beginPath(); + context.moveTo(fromPoint[0], fromPoint[1]); + context.lineTo(toPoint[0], toPoint[1]); + context.stroke(); + + context.restore(); +}; + +const renderFocusPointCicle = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + point: GlobalPoint, + radius: number, + isHovered: boolean, +) => { + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.strokeStyle = "rgba(134, 131, 226, 0.6)"; + context.lineWidth = 1 / appState.zoom.value; + context.setLineDash([]); + context.fillStyle = isHovered + ? "rgba(134, 131, 226, 0.9)" + : "rgba(255, 255, 255, 0.9)"; + + fillCircle( + context, + point[0], + point[1], + radius / appState.zoom.value, + true, + true, + ); + context.restore(); +}; + +const renderFocusPointIndicator = ({ + arrow, + appState, + type, + context, + elementsMap, +}: { + arrow: NonDeleted; + appState: InteractiveCanvasAppState; + context: CanvasRenderingContext2D; + elementsMap: NonDeletedSceneElementsMap; + type: "start" | "end"; +}) => { + const binding = type === "start" ? arrow.startBinding : arrow.endBinding; + const bindableElement = + binding?.elementId && elementsMap.get(binding.elementId); + + if ( + !bindableElement || + !isBindableElement(bindableElement) || + bindableElement.isDeleted + ) { + return; + } + + const focusPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindableElement, + elementsMap, + ); + + // Only render if focus point is within the bindable element + if ( + !isFocusPointVisible( + focusPoint, + arrow, + bindableElement, + elementsMap, + appState, + type, + ) + ) { + return; + } + + const linearState = appState.selectedLinearElement; + const isDragging = !!linearState?.isDragging; + const pointIndex = type === "start" ? 0 : arrow.points.length - 1; + const pointSelected = + !!linearState?.selectedPointsIndices?.includes(pointIndex); + + // render focus point highlight + // ---------------------------- + + if ( + linearState?.hoveredFocusPointBinding === type && + !linearState.draggedFocusPointBinding + ) { + renderFocusPointHighlight(context, appState, focusPoint); + } + + // render focus point + // ---------------------------- + + if (!(pointSelected && isDragging)) { + const focusPoint = getGlobalFixedPointForBindableElement( + binding.fixedPoint, + bindableElement, + elementsMap, + ); + + const isHovered = linearState?.hoveredFocusPointBinding === type; + + // Render dashed line from arrow start point to focus point + const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( + arrow, + pointIndex, + elementsMap, + ); + + renderFocusPointConnectionLine(context, appState, arrowPoint, focusPoint); + + renderFocusPointCicle( + context, + appState, + focusPoint, + FOCUS_POINT_SIZE / 1.5, + isHovered, + ); + } +}; + const renderTransformHandles = ( context: CanvasRenderingContext2D, renderConfig: InteractiveCanvasRenderConfig, @@ -1251,26 +1653,44 @@ const _renderInteractiveScene = ({ ); } + const linearState = appState.selectedLinearElement; + const selectedLinearElement = + linearState && + LinearElementEditor.getElement(linearState.elementId, allElementsMap); // Arrows have a different highlight behavior when // they are the only selected element - if (appState.selectedLinearElement) { - const editor = appState.selectedLinearElement; - const firstSelectedLinear = selectedElements.find( - (el) => el.id === editor.elementId, // Don't forget bound text elements! - ); - + if (selectedLinearElement) { if (!appState.selectedLinearElement.isDragging) { - if (editor.segmentMidPointHoveredCoords) { + if (linearState.segmentMidPointHoveredCoords) { renderElbowArrowMidPointHighlight(context, appState); } else if ( - isElbowArrow(firstSelectedLinear) - ? editor.hoverPointIndex === 0 || - editor.hoverPointIndex === firstSelectedLinear.points.length - 1 - : editor.hoverPointIndex >= 0 + isElbowArrow(selectedLinearElement) + ? linearState.hoverPointIndex === 0 || + linearState.hoverPointIndex === + selectedLinearElement.points.length - 1 + : linearState.hoverPointIndex >= 0 ) { renderLinearElementPointHighlight(context, appState, elementsMap); } } + + if (isArrowElement(selectedLinearElement)) { + renderFocusPointIndicator({ + arrow: selectedLinearElement, + elementsMap: allElementsMap, + appState, + context, + type: "start", + }); + + renderFocusPointIndicator({ + arrow: selectedLinearElement, + elementsMap: allElementsMap, + appState, + context, + type: "end", + }); + } } // Paint selected elements diff --git a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap index 1d92ee37e0..f07f054977 100644 --- a/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/MermaidToExcalidraw.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Test > should open mermaid popup when active tool is mermaid 1`] = ` -"