Procházet zdrojové kódy

Reintroduce multi-point arrows and add migration for it (#635)

* Revert "Revert "Feature: Multi Point Arrows (#338)" (#634)"

This reverts commit 3d2e59bfed4fa41a0cae49ee567a6f95ca26e7bf.

* Convert old arrow spec to new one

* Remove unnecessary failchecks and fix context transform issue in retina displays

* Remove old points failcheck from getArrowAbsoluteBounds

* Remove all failchecks for old arrow

* remove the rest of unnecessary checks

* Set default values for the arrow during import

* Add translations

* fix restore using unmigrated elements for state computation

* don't use width/height when migrating from new arrow spec

Co-authored-by: David Luzar <luzar.david@gmail.com>
Co-authored-by: Christopher Chedeau <vjeuxx@gmail.com>
Gasim Gasimzada před 5 roky
rodič
revize
1e4ce77612

+ 67 - 0
public/locales/de/translation.json

@@ -0,0 +1,67 @@
+{
+  "alerts": {
+    "cannotExportEmptyCanvas": "Leere Zeichenfläche kann nicht exportiert werden.",
+    "clearReset": "Dies wird die ganze Zeichenfläche löschen. Bist du dir sicher?",
+    "copiedToClipboard": "In Zwischenablage kopiert: {{url}}",
+    "couldNotCopyToClipboard": "Konnte nicht in die Zwischenablage kopieren. Versuch es mit dem Chrome Browser.",
+    "couldNotCreateShareableLink": "Konnte keinen teilbaren Link erstellen.",
+    "importBackendFailed": "Import vom Server ist fehlgeschlagen."
+  },
+  "buttons": {
+    "clearReset": "Zeichenfläche löschen & Hintergrundfarbe zurücksetzen",
+    "copyToClipboard": "In die Zwischenablage kopieren",
+    "export": "Export",
+    "exportToPng": "Als PNG exportieren",
+    "exportToSvg": "Als SVG exportieren",
+    "getShareableLink": "Teilbaren Link erhalten",
+    "load": "Laden",
+    "save": "Speichern"
+  },
+  "labels": {
+    "architect": "Architekt",
+    "artist": "Künstler",
+    "background": "Hintergrund",
+    "bold": "Fett",
+    "bringForward": "Nach vorne",
+    "bringToFront": "In den Vordergrund",
+    "cartoonist": "Karikaturist",
+    "code": "Code",
+    "copy": "Kopieren",
+    "copyStyles": "Stile kopieren",
+    "crossHatch": "Kreuzschraffiert",
+    "delete": "Löschen",
+    "extraBold": "Extra Fett",
+    "fill": "Füllung",
+    "fontFamily": "Schriftart",
+    "fontSize": "Schriftgröße",
+    "hachure": "Schraffiert",
+    "handDrawn": "Handschrift",
+    "large": "Groß",
+    "medium": "Mittel",
+    "normal": "Normal",
+    "onlySelected": "Nur ausgewählte",
+    "opacity": "Sichtbarkeit",
+    "paste": "Einfügen",
+    "pasteStyles": "Stile einfügen",
+    "selectAll": "Alle auswählen",
+    "sendBackward": "Nach hinten",
+    "sendToBack": "In den Hintergrund",
+    "sloppiness": "Sauberkeit",
+    "small": "Klein",
+    "solid": "Solide",
+    "stroke": "Strich",
+    "strokeWidth": "Strichstärke",
+    "thin": "Dünn",
+    "veryLarge": "Sehr Groß",
+    "withBackground": "Mit Hintergrund"
+  },
+  "toolBar": {
+    "arrow": "Pfeil",
+    "diamond": "Raute",
+    "ellipse": "Ellipse",
+    "line": "Linie",
+    "rectangle": "Rechteck",
+    "selection": "Auswahl",
+    "text": "Text"
+  }
+}

+ 80 - 0
public/locales/en/translation.json

@@ -0,0 +1,80 @@
+{
+  "labels": {
+    "paste": "Paste",
+    "selectAll": "Select All",
+    "copy": "Copy",
+    "bringForward": "Bring Forward",
+    "sendToBack": "Send To Back",
+    "bringToFront": "Bring To Front",
+    "sendBackward": "Send Backward",
+    "delete": "Delete",
+    "copyStyles": "Copy Styles",
+    "pasteStyles": "Paste Styles",
+    "stroke": "Stroke",
+    "background": "Background",
+    "fill": "Fill",
+    "strokeWidth": "Stroke Width",
+    "sloppiness": "Sloppiness",
+    "opacity": "Opacity",
+    "fontSize": "Font Size",
+    "fontFamily": "Font Family",
+    "onlySelected": "Only selected",
+    "withBackground": "With Background",
+    "handDrawn": "Hand-Drawn",
+    "normal": "Normal",
+    "code": "Code",
+    "small": "Small",
+    "medium": "Medium",
+    "large": "Large",
+    "veryLarge": "Very Large",
+    "solid": "Solid",
+    "hachure": "Hachure",
+    "crossHatch": "Cross-Hatch",
+    "thin": "Thin",
+    "bold": "Bold",
+    "extraBold": "Extra Bold",
+    "architect": "Architect",
+    "artist": "Artist",
+    "cartoonist": "Cartoonist",
+    "fileTitle": "File title",
+    "colorPicker": "Color picker",
+    "canvasBackground": "Canvas background",
+    "drawingCanvas": "Drawing Canvas"
+  },
+  "buttons": {
+    "clearReset": "Clear the canvas & reset background color",
+    "export": "Export",
+    "exportToPng": "Export to PNG",
+    "exportToSvg": "Export to SVG",
+    "copyToClipboard": "Copy to clipboard",
+    "save": "Save",
+    "load": "Load",
+    "getShareableLink": "Get shareable link",
+    "close": "Close",
+    "selectLanguage": "Select Language",
+    "previouslyLoadedScenes": "Previously loaded scenes"
+  },
+  "alerts": {
+    "clearReset": "This will clear the whole canvas. Are you sure?",
+    "couldNotCreateShareableLink": "Couldn't create shareable link.",
+    "importBackendFailed": "Importing from backend failed.",
+    "cannotExportEmptyCanvas": "Cannot export empty canvas.",
+    "couldNotCopyToClipboard": "Couldn't copy to clipboard. Try using Chrome browser.",
+    "copiedToClipboard": "Copied to clipboard: {{url}}"
+  },
+  "toolBar": {
+    "selection": "Selection",
+    "rectangle": "Rectangle",
+    "diamond": "Diamond",
+    "ellipse": "Ellipse",
+    "arrow": "Arrow",
+    "line": "Line",
+    "text": "Text",
+    "lock": "Keep selected tool active after drawing"
+  },
+  "headings": {
+    "canvasActions": "Canvas actions",
+    "selectedShapeActions": "Selected shape actions",
+    "shapes": "Shapes"
+  }
+}

+ 81 - 0
public/locales/es/translation.json

@@ -0,0 +1,81 @@
+{
+  "labels": {
+    "paste": "Pegar",
+    "selectAll": "Seleccionar todo",
+    "copy": "Copiar",
+    "bringForward": "Adelantar",
+    "sendToBack": "Send To Back",
+    "bringToFront": "Traer al frente",
+    "sendBackward": "Enviar átras",
+    "delete": "Borrar",
+    "copyStyles": "Copiar estilos",
+    "pasteStyles": "Pegar estilos",
+    "stroke": "Trazo",
+    "background": "Fondo",
+    "fill": "Rellenar",
+    "strokeWidth": "Ancho de trazo",
+    "sloppiness": "Estilo de trazo",
+    "opacity": "Opacidad",
+    "fontSize": "Tamaño de letra",
+    "fontFamily": "Tipo de letra",
+    "onlySelected": "Sólo seleccionados",
+    "withBackground": "Con fondo",
+    "handDrawn": "Dibujo a Mano",
+    "normal": "Normal",
+    "code": "Código",
+    "small": "Pequeña",
+    "medium": "Mediana",
+    "large": "Grande",
+    "veryLarge": "Muy Grande",
+    "solid": "Sólido",
+    "hachure": "Folleto",
+    "crossHatch": "Rayado transversal",
+    "thin": "Fino",
+    "bold": "Grueso",
+    "extraBold": "Extra Grueso",
+    "architect": "Arquitecto",
+    "artist": "Artista",
+    "cartoonist": "Caricatura",
+    "fileTitle": "Título del archivo",
+    "colorPicker": "Selector de color",
+    "canvasBackground": "Fondo del lienzo",
+    "drawingCanvas": "Lienzo de dibujo"
+  },
+  "buttons": {
+    "clearReset": "Limpiar lienzo y reiniciar el color de fondo",
+    "export": "Exportar",
+    "exportToPng": "Exportar a PNG",
+    "exportToSvg": "Exportar a SVG",
+    "copyToClipboard": "Copiar al portapapeles",
+    "save": "Guardar",
+    "load": "Cargar",
+    "getShareableLink": "Obtener enlace para compartir",
+    "showExportDialog": "Mostrar diálogo para exportar",
+    "close": "Cerrar",
+    "selectLanguage": "Seleccionar idioma",
+    "previouslyLoadedScenes": "Escenas previamente cargadas"
+  },
+  "alerts": {
+    "clearReset": "Esto limpiará todo el lienzo. Estás seguro?",
+    "couldNotCreateShareableLink": "No se pudo crear un enlace para compartir.",
+    "importBackendFailed": "La importación falló.",
+    "cannotExportEmptyCanvas": "No se puede exportar un lienzo vació",
+    "couldNotCopyToClipboard": "No se ha podido copiar al portapapeles, intente usar Chrome como navegador.",
+    "copiedToClipboard": "Copiado en el portapapeles: {{url}}"
+  },
+  "toolBar": {
+    "selection": "Selección",
+    "rectangle": "Rectángulo",
+    "diamond": "Diamante",
+    "ellipse": "Elipse",
+    "arrow": "Flecha",
+    "line": "Línea",
+    "text": "Texto",
+    "lock": "Mantener la herramienta seleccionada activa después de dibujar"
+  },
+  "headings": {
+    "canvasActions": "Acciones del lienzo",
+    "selectedShapeActions": "Acciones de la forma seleccionada",
+    "shapes": "Formas"
+  }
+}

+ 68 - 0
public/locales/fr/translation.json

@@ -0,0 +1,68 @@
+{
+  "labels": {
+    "paste": "Coller",
+    "selectAll": "Tout sélectionner",
+    "copy": "Copier",
+    "bringForward": "Mettre en avant",
+    "sendToBack": "Mettre en arrière-plan",
+    "bringToFront": "Mettre au premier plan",
+    "sendBackward": "Mettre en arrière",
+    "delete": "Supprimer",
+    "copyStyles": "Copier les styles",
+    "pasteStyles": "Coller les styles",
+    "stroke": "Contour",
+    "background": "Arrière-plan",
+    "fill": "Remplissage",
+    "strokeWidth": "Épaisseur contour",
+    "sloppiness": "Laisser-aller",
+    "opacity": "Opacité",
+    "fontSize": "Taille police",
+    "fontFamily": "Police",
+    "onlySelected": "Uniquement la sélection",
+    "withBackground": "Avec arrière-plan",
+    "handDrawn": "Manuscrite",
+    "normal": "Normale",
+    "code": "Code",
+    "small": "Petit",
+    "medium": "Moyen",
+    "large": "Large",
+    "veryLarge": "Très Large",
+    "solid": "Solide",
+    "hachure": "Hachure",
+    "crossHatch": "Hachure croisée",
+    "thin": "Fin",
+    "bold": "Épais",
+    "extraBold": "Très épais",
+    "architect": "Architecte",
+    "artist": "Artiste",
+    "cartoonist": "Cartooniste"
+  },
+  "buttons": {
+    "clearReset": "Effacer le canvas & réinitialiser la couleur d'arrière-plan",
+    "export": "Exporter",
+    "exportToPng": "Exporter en PNG",
+    "exportToSvg": "Exporter en SVG",
+    "copyToClipboard": "Copier dans le presse-papier",
+    "save": "Sauvegarder",
+    "load": "Ouvrir",
+    "getShareableLink": "Obtenir un lien de partage",
+    "previouslyLoadedScenes": "Scènes précédemment chargées"
+  },
+  "alerts": {
+    "clearReset": "L'intégralité du canvas va être effacé. Êtes-vous sur ?",
+    "couldNotCreateShareableLink": "Impossible de créer un lien de partage.",
+    "importBackendFailed": "L'import depuis le backend a échoué.",
+    "cannotExportEmptyCanvas": "Impossible d'exporter un canvas vide.",
+    "couldNotCopyToClipboard": "Impossible de copier dans le presse-papier. Essayez d'utiliser le navigateur Chrome.",
+    "copiedToClipboard": "Copié dans le presse-papier: {{url}}"
+  },
+  "toolBar": {
+    "selection": "Sélection",
+    "rectangle": "Rectangle",
+    "diamond": "Losange",
+    "ellipse": "Ellipse",
+    "arrow": "Flèche",
+    "line": "Ligne",
+    "text": "Texte"
+  }
+}

+ 68 - 0
public/locales/pt/translation.json

@@ -0,0 +1,68 @@
+{
+  "labels": {
+    "paste": "Colar",
+    "selectAll": "Selecionar tudo",
+    "copy": "Copiar",
+    "bringForward": "Passar para o primeiro plano",
+    "sendToBack": "Passar para trás",
+    "bringToFront": "Passar para frente",
+    "sendBackward": "Passar para o plano de fundo",
+    "delete": "Apagar",
+    "copyStyles": "Copiar os estilos",
+    "pasteStyles": "Colar os estilos",
+    "stroke": "Contornos",
+    "background": "Fundo",
+    "fill": "Preenchimento",
+    "strokeWidth": "Espessura dos contornos",
+    "sloppiness": "Desleixo",
+    "opacity": "Opacidade",
+    "fontSize": "Tamanho da fonte",
+    "fontFamily": "Fonte",
+    "onlySelected": "Somente a seleção",
+    "withBackground": "Com fundo",
+    "handDrawn": "Manuscrito",
+    "normal": "Normal",
+    "code": "Código",
+    "small": "Pequeno",
+    "medium": "Médio",
+    "large": "Grande",
+    "veryLarge": "Muito Grande",
+    "solid": "Sólido",
+    "hachure": "Eclosão",
+    "crossHatch": "Eclosão cruzada",
+    "thin": "Fino",
+    "bold": "Espesso",
+    "extraBold": "Muito espesso",
+    "architect": "Arquitecto",
+    "artist": "Artista",
+    "cartoonist": "Caricaturista"
+  },
+  "buttons": {
+    "clearReset": "Limpar o canvas e redefinir a cor de fundo",
+    "export": "Exportar",
+    "exportToPng": "Exportar em PNG",
+    "exportToSvg": "Exportar em SVG",
+    "copyToClipboard": "Copiar para o clipboard",
+    "save": "Guardar",
+    "load": "Carregar",
+    "getShareableLink": "Obter um link de partilha",
+    "previouslyLoadedScenes": "Cenas carregadas anteriormente"
+  },
+  "alerts": {
+    "clearReset": "O canvas inteiro será excluído. Tens a certeza?",
+    "couldNotCreateShareableLink": "Não foi possível criar um link de partilha.",
+    "importBackendFailed": "O carregamento no servidor falhou.",
+    "cannotExportEmptyCanvas": "Não é possível exportar um canvas vazío.",
+    "couldNotCopyToClipboard": "Não foi possível copiar no clipboard. Experimente no navegador Chrome.",
+    "copiedToClipboard": "Copiado no clipboard: {{url}}"
+  },
+  "toolBar": {
+    "selection": "Seleção",
+    "rectangle": "Retângulo",
+    "diamond": "Losango",
+    "ellipse": "Elipse",
+    "arrow": "Flecha",
+    "line": "Linha",
+    "text": "Texto"
+  }
+}

+ 80 - 0
public/locales/ru/translation.json

@@ -0,0 +1,80 @@
+{
+  "labels": {
+    "paste": "Вставить",
+    "selectAll": "Выделить всё",
+    "copy": "Копировать",
+    "bringForward": "Переложить вперёд",
+    "sendToBack": "На задний план",
+    "bringToFront": "На передний план",
+    "sendBackward": "Переложить назад",
+    "delete": "Удалить",
+    "copyStyles": "Скопировать стили",
+    "pasteStyles": "Вставить стили",
+    "stroke": "Обводка",
+    "background": "Фон",
+    "fill": "Заливка",
+    "strokeWidth": "Толщина обводки",
+    "sloppiness": "Стиль обводки",
+    "opacity": "Непрозрачность",
+    "fontSize": "Размер шрифта",
+    "fontFamily": "Семейство шрифта",
+    "onlySelected": "Только выбранные",
+    "withBackground": "с фоном",
+    "handDrawn": "Нарисованный от руки",
+    "normal": "Обычный",
+    "code": "Код",
+    "small": "Малый",
+    "medium": "Средний",
+    "large": "Большой",
+    "veryLarge": "Очень Большой",
+    "solid": "Однотонная",
+    "hachure": "Штрихованная",
+    "crossHatch": "Перекрестная",
+    "thin": "Тонкая",
+    "bold": "Жирная",
+    "extraBold": "Очень Жирная",
+    "architect": "Архитектор",
+    "artist": "Художник",
+    "cartoonist": "Карикатурист",
+    "fileTitle": "Название файла",
+    "colorPicker": "Выбор цвета",
+    "canvasBackground": "Фон холста",
+    "drawingCanvas": "Рисование холста"
+  },
+  "buttons": {
+    "clearReset": "Очистить холст & сбросить цвет фона",
+    "export": "Экспортировать",
+    "exportToPng": "Экспорт в PNG",
+    "exportToSvg": "Экспорт в SVG",
+    "copyToClipboard": "Скопировать в буфер обмена",
+    "save": "Сохранить",
+    "load": "Загрузить",
+    "getShareableLink": "Получить доступ по ссылке",
+    "close": "Закрыть",
+    "selectLanguage": "Выбрать язык",
+    "previouslyLoadedScenes": "Ранее загруженные сцены"
+  },
+  "alerts": {
+    "clearReset": "Это очистит весь холст. Вы уверены?",
+    "couldNotCreateShareableLink": "Не удалось создать общедоступную ссылку.",
+    "importBackendFailed": "Не удалось импортировать из бэкэнда.",
+    "cannotExportEmptyCanvas": "Не может экспортировать пустой холст.",
+    "couldNotCopyToClipboard": "Не удалось скопировать в буфер обмена. Попробуйте использовать веб-браузер Chrome.",
+    "copiedToClipboard": "Скопировано в буфер обмена: {{url}}"
+  },
+  "toolBar": {
+    "selection": "Выделение области",
+    "rectangle": "Прямоугольник",
+    "diamond": "Ромб",
+    "ellipse": "Эллипс",
+    "arrow": "Cтрелка",
+    "line": "Линия",
+    "text": "Текст",
+    "lock": "Сохранять выбранный инструмент активным после рисования"
+  },
+  "headings": {
+    "canvasActions": "Операции холста",
+    "selectedShapeActions": "Операции выбранной фигуры",
+    "shapes": "Фигуры"
+  }
+}

+ 2 - 1
src/actions/actionDeleteSelected.tsx

@@ -4,9 +4,10 @@ import { KEYS } from "../keys";
 
 export const actionDeleteSelected: Action = {
   name: "deleteSelectedElements",
-  perform: elements => {
+  perform: (elements, appState) => {
     return {
       elements: deleteSelectedElements(elements),
+      appState: { ...appState, elementType: "selection", multiElement: null },
     };
   },
   contextItemLabel: "labels.delete",

+ 27 - 0
src/actions/actionFinalize.tsx

@@ -0,0 +1,27 @@
+import { Action } from "./types";
+import { KEYS } from "../keys";
+import { clearSelection } from "../scene";
+
+export const actionFinalize: Action = {
+  name: "finalize",
+  perform: (elements, appState) => {
+    if (window.document.activeElement instanceof HTMLElement) {
+      window.document.activeElement.blur();
+    }
+    return {
+      elements: clearSelection(elements),
+      appState: {
+        ...appState,
+        elementType: "selection",
+        draggingElement: null,
+        multiElement: null,
+      },
+    };
+  },
+  keyTest: (event, appState) =>
+    (event.key === KEYS.ESCAPE &&
+      !appState.draggingElement &&
+      appState.multiElement === null) ||
+    ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
+      appState.multiElement !== null),
+};

+ 2 - 0
src/actions/index.ts

@@ -23,6 +23,8 @@ export {
   actionClearCanvas,
 } from "./actionCanvas";
 
+export { actionFinalize } from "./actionFinalize";
+
 export {
   actionChangeProjectName,
   actionChangeExportBackground,

+ 1 - 1
src/actions/manager.tsx

@@ -34,7 +34,7 @@ export class ActionManager implements ActionsManagerInterface {
     const data = Object.values(this.actions)
       .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
       .filter(
-        action => action.keyTest && action.keyTest(event, elements, appState),
+        action => action.keyTest && action.keyTest(event, appState, elements),
       );
 
     if (data.length === 0) return null;

+ 2 - 2
src/actions/types.ts

@@ -27,8 +27,8 @@ export interface Action {
   keyPriority?: number;
   keyTest?: (
     event: KeyboardEvent,
-    elements?: readonly ExcalidrawElement[],
-    appState?: AppState,
+    appState: AppState,
+    elements: readonly ExcalidrawElement[],
   ) => boolean;
   contextItemLabel?: string;
   contextMenuOrder?: number;

+ 7 - 0
src/appState.ts

@@ -7,6 +7,7 @@ export function getDefaultAppState(): AppState {
   return {
     draggingElement: null,
     resizingElement: null,
+    multiElement: null,
     editingElement: null,
     elementType: "selection",
     elementLocked: false,
@@ -26,3 +27,9 @@ export function getDefaultAppState(): AppState {
     name: DEFAULT_PROJECT_NAME,
   };
 }
+
+export function cleanAppStateForExport(appState: AppState) {
+  return {
+    viewBackgroundColor: appState.viewBackgroundColor,
+  };
+}

+ 94 - 5
src/element/bounds.ts

@@ -1,11 +1,16 @@
 import { ExcalidrawElement } from "./types";
 import { rotate } from "../math";
+import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 
 // If the element is created from right to left, the width is going to be negative
 // This set of functions retrieves the absolute position of the 4 points.
 // We can't just always normalize it since we need to remember the fact that an arrow
 // is pointing left or right.
 export function getElementAbsoluteCoords(element: ExcalidrawElement) {
+  if (element.type === "arrow") {
+    return getArrowAbsoluteBounds(element);
+  }
   return [
     element.width >= 0 ? element.x : element.x + element.width, // x1
     element.height >= 0 ? element.y : element.y + element.height, // y1
@@ -29,11 +34,95 @@ export function getDiamondPoints(element: ExcalidrawElement) {
   return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
 }
 
+export function getArrowAbsoluteBounds(element: ExcalidrawElement) {
+  if (element.points.length < 2 || !element.shape) {
+    const { minX, minY, maxX, maxY } = element.points.reduce(
+      (limits, [x, y]) => {
+        limits.minY = Math.min(limits.minY, y);
+        limits.minX = Math.min(limits.minX, x);
+
+        limits.maxX = Math.max(limits.maxX, x);
+        limits.maxY = Math.max(limits.maxY, y);
+
+        return limits;
+      },
+      { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+    );
+    return [
+      minX + element.x,
+      minY + element.y,
+      maxX + element.x,
+      maxY + element.y,
+    ];
+  }
+
+  const shape = element.shape as Drawable[];
+
+  const ops = shape[1].sets[0].ops;
+
+  let currentP: Point = [0, 0];
+
+  const { minX, minY, maxX, maxY } = ops.reduce(
+    (limits, { op, data }) => {
+      // There are only four operation types:
+      // move, bcurveTo, lineTo, and curveTo
+      if (op === "move") {
+        // change starting point
+        currentP = data as Point;
+        // move operation does not draw anything; so, it always
+        // returns false
+      } else if (op === "bcurveTo") {
+        // create points from bezier curve
+        // bezier curve stores data as a flattened array of three positions
+        // [x1, y1, x2, y2, x3, y3]
+        const p1 = [data[0], data[1]] as Point;
+        const p2 = [data[2], data[3]] as Point;
+        const p3 = [data[4], data[5]] as Point;
+
+        const p0 = currentP;
+        currentP = p3;
+
+        const equation = (t: number, idx: number) =>
+          Math.pow(1 - t, 3) * p3[idx] +
+          3 * t * Math.pow(1 - t, 2) * p2[idx] +
+          3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+          p0[idx] * Math.pow(t, 3);
+
+        let t = 0;
+        while (t <= 1.0) {
+          const x = equation(t, 0);
+          const y = equation(t, 1);
+
+          limits.minY = Math.min(limits.minY, y);
+          limits.minX = Math.min(limits.minX, x);
+
+          limits.maxX = Math.max(limits.maxX, x);
+          limits.maxY = Math.max(limits.maxY, y);
+
+          t += 0.1;
+        }
+      } else if (op === "lineTo") {
+        // TODO: Implement this
+      } else if (op === "qcurveTo") {
+        // TODO: Implement this
+      }
+      return limits;
+    },
+    { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
+  );
+
+  return [
+    minX + element.x,
+    minY + element.y,
+    maxX + element.x,
+    maxY + element.y,
+  ];
+}
+
 export function getArrowPoints(element: ExcalidrawElement) {
-  const x1 = 0;
-  const y1 = 0;
-  const x2 = element.width;
-  const y2 = element.height;
+  const points = element.points;
+  const [x1, y1] = points.length >= 2 ? points[points.length - 2] : [0, 0];
+  const [x2, y2] = points[points.length - 1];
 
   const size = 30; // pixels
   const distance = Math.hypot(x2 - x1, y2 - y1);
@@ -46,7 +135,7 @@ export function getArrowPoints(element: ExcalidrawElement) {
   const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
   const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
 
-  return [x1, y1, x2, y2, x3, y3, x4, y4];
+  return [x2, y2, x3, y3, x4, y4];
 }
 
 export function getLinePoints(element: ExcalidrawElement) {

+ 99 - 11
src/element/collision.ts

@@ -2,11 +2,13 @@ import { distanceBetweenPointAndSegment } from "../math";
 
 import { ExcalidrawElement } from "./types";
 import {
-  getArrowPoints,
   getDiamondPoints,
   getElementAbsoluteCoords,
   getLinePoints,
+  getArrowAbsoluteBounds,
 } from "./bounds";
+import { Point } from "roughjs/bin/geometry";
+import { Drawable, OpSet } from "roughjs/bin/core";
 
 function isElementDraggableFromInside(element: ExcalidrawElement): boolean {
   return element.backgroundColor !== "transparent" || element.isSelected;
@@ -145,18 +147,25 @@ export function hitTest(
         lineThreshold
     );
   } else if (element.type === "arrow") {
-    let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
-    // The computation is done at the origin, we need to add a translation
-    x -= element.x;
-    y -= element.y;
+    if (!element.shape) {
+      return false;
+    }
+    const shape = element.shape as Drawable[];
+    // If shape does not consist of curve and two line segments
+    // for arrow shape, return false
+    if (shape.length < 3) return false;
+
+    const [x1, y1, x2, y2] = getArrowAbsoluteBounds(element);
+    if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) return false;
+
+    const relX = x - element.x;
+    const relY = y - element.y;
 
+    // hit test curve and lien segments for arrow
     return (
-      //    \
-      distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold ||
-      // -----
-      distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold ||
-      //    /
-      distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold
+      hitTestRoughShape(shape[0].sets, relX, relY) ||
+      hitTestRoughShape(shape[1].sets, relX, relY) ||
+      hitTestRoughShape(shape[2].sets, relX, relY)
     );
   } else if (element.type === "line") {
     const [x1, y1, x2, y2] = getLinePoints(element);
@@ -176,3 +185,82 @@ export function hitTest(
     throw new Error("Unimplemented type " + element.type);
   }
 }
+
+const pointInBezierEquation = (
+  p0: Point,
+  p1: Point,
+  p2: Point,
+  p3: Point,
+  [mx, my]: Point,
+) => {
+  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+  const equation = (t: number, idx: number) =>
+    Math.pow(1 - t, 3) * p3[idx] +
+    3 * t * Math.pow(1 - t, 2) * p2[idx] +
+    3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+    p0[idx] * Math.pow(t, 3);
+
+  const epsilon = 20;
+  // go through t in increments of 0.01
+  let t = 0;
+  while (t <= 1.0) {
+    const tx = equation(t, 0);
+    const ty = equation(t, 1);
+
+    const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
+
+    if (diff < epsilon) {
+      return true;
+    }
+
+    t += 0.01;
+  }
+
+  return false;
+};
+
+const hitTestRoughShape = (opSet: OpSet[], x: number, y: number) => {
+  // read operations from first opSet
+  const ops = opSet[0].ops;
+
+  // set start position as (0,0) just in case
+  // move operation does not exist (unlikely but it is worth safekeeping it)
+  let currentP: Point = [0, 0];
+
+  return ops.some(({ op, data }, idx) => {
+    // There are only four operation types:
+    // move, bcurveTo, lineTo, and curveTo
+    if (op === "move") {
+      // change starting point
+      currentP = data as Point;
+      // move operation does not draw anything; so, it always
+      // returns false
+    } else if (op === "bcurveTo") {
+      // create points from bezier curve
+      // bezier curve stores data as a flattened array of three positions
+      // [x1, y1, x2, y2, x3, y3]
+      const p1 = [data[0], data[1]] as Point;
+      const p2 = [data[2], data[3]] as Point;
+      const p3 = [data[4], data[5]] as Point;
+
+      const p0 = currentP;
+      currentP = p3;
+
+      // check if points are on the curve
+      // cubic bezier curves require four parameters
+      // the first parameter is the last stored position (p0)
+      let retVal = pointInBezierEquation(p0, p1, p2, p3, [x, y]);
+
+      // set end point of bezier curve as the new starting point for
+      // upcoming operations as each operation is based on the last drawn
+      // position of the previous operation
+      return retVal;
+    } else if (op === "lineTo") {
+      // TODO: Implement this
+    } else if (op === "qcurveTo") {
+      // TODO: Implement this
+    }
+
+    return false;
+  });
+};

+ 70 - 9
src/element/handlerRectangles.ts

@@ -1,5 +1,6 @@
 import { ExcalidrawElement } from "./types";
 import { SceneScroll } from "../scene/types";
+import { getArrowAbsoluteBounds } from "./bounds";
 
 type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
 
@@ -7,18 +8,31 @@ export function handlerRectangles(
   element: ExcalidrawElement,
   { scrollX, scrollY }: SceneScroll,
 ) {
-  const elementX1 = element.x;
-  const elementX2 = element.x + element.width;
-  const elementY1 = element.y;
-  const elementY2 = element.y + element.height;
+  let elementX2 = 0;
+  let elementY2 = 0;
+  let elementX1 = Infinity;
+  let elementY1 = Infinity;
+  let marginX = -8;
+  let marginY = -8;
+
+  let minimumSize = 40;
+  if (element.type === "arrow") {
+    [elementX1, elementY1, elementX2, elementY2] = getArrowAbsoluteBounds(
+      element,
+    );
+  } else {
+    elementX1 = element.x;
+    elementX2 = element.x + element.width;
+    elementY1 = element.y;
+    elementY2 = element.y + element.height;
+
+    marginX = element.width < 0 ? 8 : -8;
+    marginY = element.height < 0 ? 8 : -8;
+  }
 
   const margin = 4;
-  const minimumSize = 40;
   const handlers = {} as { [T in Sides]: number[] };
 
-  const marginX = element.width < 0 ? 8 : -8;
-  const marginY = element.height < 0 ? 8 : -8;
-
   if (Math.abs(elementX2 - elementX1) > minimumSize) {
     handlers["n"] = [
       elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
@@ -76,11 +90,58 @@ export function handlerRectangles(
     8,
   ]; // se
 
-  if (element.type === "arrow" || element.type === "line") {
+  if (element.type === "line") {
     return {
       nw: handlers.nw,
       se: handlers.se,
     } as typeof handlers;
+  } else if (element.type === "arrow") {
+    if (element.points.length === 2) {
+      // only check the last point because starting point is always (0,0)
+      const [, p1] = element.points;
+
+      if (p1[0] === 0 || p1[1] === 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+
+      if (p1[0] > 0 && p1[1] < 0) {
+        return {
+          ne: handlers.ne,
+          sw: handlers.sw,
+        } as typeof handlers;
+      }
+
+      if (p1[0] > 0 && p1[1] > 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+
+      if (p1[0] < 0 && p1[1] > 0) {
+        return {
+          ne: handlers.ne,
+          sw: handlers.sw,
+        } as typeof handlers;
+      }
+
+      if (p1[0] < 0 && p1[1] < 0) {
+        return {
+          nw: handlers.nw,
+          se: handlers.se,
+        } as typeof handlers;
+      }
+    }
+
+    return {
+      n: handlers.n,
+      s: handlers.s,
+      w: handlers.w,
+      e: handlers.e,
+    } as typeof handlers;
   }
 
   return handlers;

+ 1 - 0
src/element/index.ts

@@ -5,6 +5,7 @@ export {
   getDiamondPoints,
   getArrowPoints,
   getLinePoints,
+  getArrowAbsoluteBounds,
 } from "./bounds";
 
 export { handlerRectangles } from "./handlerRectangles";

+ 2 - 0
src/element/newElement.ts

@@ -1,6 +1,7 @@
 import { randomSeed } from "roughjs/bin/math";
 import nanoid from "nanoid";
 import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 
 import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
 import { measureText } from "../utils";
@@ -34,6 +35,7 @@ export function newElement(
     isSelected: false,
     seed: randomSeed(),
     shape: null as Drawable | Drawable[] | null,
+    points: [] as Point[],
   };
   return element;
 }

+ 1 - 0
src/element/resizeTest.ts

@@ -17,6 +17,7 @@ export function resizeTest(
 
   const filter = Object.keys(handlers).filter(key => {
     const handler = handlers[key as HandlerRectanglesRet]!;
+    if (!handler) return false;
 
     return (
       x + scrollX >= handler[0] &&

+ 351 - 59
src/index.tsx

@@ -44,10 +44,11 @@ import { ExcalidrawElement } from "./element/types";
 
 import {
   isInputLike,
-  isToolIcon,
   debounce,
   capitalizeString,
   distance,
+  distance2d,
+  isToolIcon,
 } from "./utils";
 import { KEYS, isArrowKey } from "./keys";
 
@@ -82,6 +83,7 @@ import {
   actionSaveScene,
   actionCopyStyles,
   actionPasteStyles,
+  actionFinalize,
 } from "./actions";
 import { Action, ActionResult } from "./actions/types";
 import { getDefaultAppState } from "./appState";
@@ -92,6 +94,7 @@ import { ToolButton } from "./components/ToolButton";
 import { LockIcon } from "./components/LockIcon";
 import { ExportDialog } from "./components/ExportDialog";
 import { LanguageList } from "./components/LanguageList";
+import { Point } from "roughjs/bin/geometry";
 import { t, languages, setLanguage, getLanguage } from "./i18n";
 import { StoredScenesList } from "./components/StoredScenesList";
 
@@ -114,6 +117,7 @@ function setCursorForShape(shape: string) {
   }
 }
 
+const DRAGGING_THRESHOLD = 10; // 10px
 const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
 const ELEMENT_TRANSLATE_AMOUNT = 1;
 const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
@@ -173,6 +177,7 @@ export class App extends React.Component<any, AppState> {
   canvasOnlyActions: Array<Action>;
   constructor(props: any) {
     super(props);
+    this.actionManager.registerAction(actionFinalize);
     this.actionManager.registerAction(actionDeleteSelected);
     this.actionManager.registerAction(actionSendToBack);
     this.actionManager.registerAction(actionBringToFront);
@@ -333,16 +338,7 @@ export class App extends React.Component<any, AppState> {
   };
 
   private onKeyDown = (event: KeyboardEvent) => {
-    if (event.key === KEYS.ESCAPE && !this.state.draggingElement) {
-      elements = clearSelection(elements);
-      this.setState({ elementType: "selection" });
-      if (window.document.activeElement instanceof HTMLElement) {
-        window.document.activeElement.blur();
-      }
-      event.preventDefault();
-      return;
-    }
-    if (isInputLike(event.target)) return;
+    if (isInputLike(event.target) && event.key !== KEYS.ESCAPE) return;
 
     const actionResult = this.actionManager.handleKeyDown(
       event,
@@ -390,19 +386,27 @@ export class App extends React.Component<any, AppState> {
     } else if (event[KEYS.META] && event.code === "KeyZ") {
       event.preventDefault();
 
+      if (
+        this.state.resizingElement ||
+        this.state.multiElement ||
+        this.state.editingElement
+      ) {
+        return;
+      }
+
       if (event.shiftKey) {
         // Redo action
         const data = history.redoOnce();
         if (data !== null) {
           elements = data.elements;
-          this.setState(data.appState);
+          this.setState({ ...data.appState });
         }
       } else {
         // undo action
         const data = history.undoOnce();
         if (data !== null) {
           elements = data.elements;
-          this.setState(data.appState);
+          this.setState({ ...data.appState });
         }
       }
     } else if (event.key === KEYS.SPACE && !isHoldingMouseButton) {
@@ -561,7 +565,7 @@ export class App extends React.Component<any, AppState> {
               aria-label={capitalizeString(label)}
               aria-keyshortcuts={`${label[0]} ${index + 1}`}
               onChange={() => {
-                this.setState({ elementType: value });
+                this.setState({ elementType: value, multiElement: null });
                 elements = clearSelection(elements);
                 document.documentElement.style.cursor =
                   value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR;
@@ -1018,11 +1022,28 @@ export class App extends React.Component<any, AppState> {
                   editingElement: element,
                 });
                 return;
+              } else if (this.state.elementType === "arrow") {
+                if (this.state.multiElement) {
+                  const { multiElement } = this.state;
+                  const { x: rx, y: ry } = multiElement;
+                  multiElement.isSelected = true;
+                  multiElement.points.push([x - rx, y - ry]);
+                  multiElement.shape = null;
+                  this.setState({ draggingElement: multiElement });
+                } else {
+                  element.isSelected = false;
+                  element.points.push([0, 0]);
+                  element.shape = null;
+                  elements = [...elements, element];
+                  this.setState({
+                    draggingElement: element,
+                  });
+                }
+              } else {
+                elements = [...elements, element];
+                this.setState({ multiElement: null, draggingElement: element });
               }
 
-              elements = [...elements, element];
-              this.setState({ draggingElement: element });
-
               let lastX = x;
               let lastY = y;
 
@@ -1031,6 +1052,75 @@ export class App extends React.Component<any, AppState> {
                 lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP;
               }
 
+              let resizeArrowFn:
+                | ((
+                    element: ExcalidrawElement,
+                    p1: Point,
+                    deltaX: number,
+                    deltaY: number,
+                    mouseX: number,
+                    mouseY: number,
+                    perfect: boolean,
+                  ) => void)
+                | null = null;
+
+              const arrowResizeOrigin = (
+                element: ExcalidrawElement,
+                p1: Point,
+                deltaX: number,
+                deltaY: number,
+                mouseX: number,
+                mouseY: number,
+                perfect: boolean,
+              ) => {
+                // TODO: Implement perfect sizing for origin
+                if (perfect) {
+                  const absPx = p1[0] + element.x;
+                  const absPy = p1[1] + element.y;
+
+                  let { width, height } = getPerfectElementSize(
+                    "arrow",
+                    mouseX - element.x - p1[0],
+                    mouseY - element.y - p1[1],
+                  );
+
+                  const dx = element.x + width + p1[0];
+                  const dy = element.y + height + p1[1];
+                  element.x = dx;
+                  element.y = dy;
+                  p1[0] = absPx - element.x;
+                  p1[1] = absPy - element.y;
+                } else {
+                  element.x += deltaX;
+                  element.y += deltaY;
+                  p1[0] -= deltaX;
+                  p1[1] -= deltaY;
+                }
+              };
+
+              const arrowResizeEnd = (
+                element: ExcalidrawElement,
+                p1: Point,
+                deltaX: number,
+                deltaY: number,
+                mouseX: number,
+                mouseY: number,
+                perfect: boolean,
+              ) => {
+                if (perfect) {
+                  const { width, height } = getPerfectElementSize(
+                    "arrow",
+                    mouseX - element.x,
+                    mouseY - element.y,
+                  );
+                  p1[0] = width;
+                  p1[1] = height;
+                } else {
+                  p1[0] += deltaX;
+                  p1[1] += deltaY;
+                }
+              };
+
               const onMouseMove = (e: MouseEvent) => {
                 const target = e.target;
                 if (!(target instanceof HTMLElement)) {
@@ -1057,6 +1147,16 @@ export class App extends React.Component<any, AppState> {
                   return;
                 }
 
+                // for arrows, don't start dragging until a given threshold
+                //  to ensure we don't create a 2-point arrow by mistake when
+                //  user clicks mouse in a way that it moves a tiny bit (thus
+                //  triggering mousemove)
+                if (!draggingOccurred && this.state.elementType === "arrow") {
+                  const { x, y } = viewportCoordsToSceneCoords(e, this.state);
+                  if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD)
+                    return;
+                }
+
                 if (isResizingElements && this.state.resizingElement) {
                   const el = this.state.resizingElement;
                   const selectedElements = elements.filter(el => el.isSelected);
@@ -1069,73 +1169,217 @@ export class App extends React.Component<any, AppState> {
                       element.type === "line" || element.type === "arrow";
                     switch (resizeHandle) {
                       case "nw":
-                        element.width -= deltaX;
-                        element.x += deltaX;
-
-                        if (e.shiftKey) {
-                          if (isLinear) {
-                            resizePerfectLineForNWHandler(element, x, y);
-                          } else {
-                            element.y += element.height - element.width;
-                            element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+
+                          if (!resizeArrowFn) {
+                            if (p1[0] < 0 || p1[1] < 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
                           }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height -= deltaY;
-                          element.y += deltaY;
+                          element.width -= deltaX;
+                          element.x += deltaX;
+
+                          if (e.shiftKey) {
+                            if (isLinear) {
+                              resizePerfectLineForNWHandler(element, x, y);
+                            } else {
+                              element.y += element.height - element.width;
+                              element.height = element.width;
+                            }
+                          } else {
+                            element.height -= deltaY;
+                            element.y += deltaY;
+                          }
                         }
                         break;
                       case "ne":
-                        element.width += deltaX;
-                        if (e.shiftKey) {
-                          element.y += element.height - element.width;
-                          element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] >= 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height -= deltaY;
-                          element.y += deltaY;
+                          element.width += deltaX;
+                          if (e.shiftKey) {
+                            element.y += element.height - element.width;
+                            element.height = element.width;
+                          } else {
+                            element.height -= deltaY;
+                            element.y += deltaY;
+                          }
                         }
                         break;
                       case "sw":
-                        element.width -= deltaX;
-                        element.x += deltaX;
-                        if (e.shiftKey) {
-                          element.height = element.width;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] <= 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
                         } else {
-                          element.height += deltaY;
+                          element.width -= deltaX;
+                          element.x += deltaX;
+                          if (e.shiftKey) {
+                            element.height = element.width;
+                          } else {
+                            element.height += deltaY;
+                          }
                         }
                         break;
                       case "se":
-                        if (e.shiftKey) {
-                          if (isLinear) {
-                            const { width, height } = getPerfectElementSize(
-                              element.type,
-                              x - element.x,
-                              y - element.y,
-                            );
-                            element.width = width;
-                            element.height = height;
+                        if (
+                          element.type === "arrow" &&
+                          element.points.length === 2
+                        ) {
+                          const [, p1] = element.points;
+                          if (!resizeArrowFn) {
+                            if (p1[0] > 0 || p1[1] > 0) {
+                              resizeArrowFn = arrowResizeEnd;
+                            } else {
+                              resizeArrowFn = arrowResizeOrigin;
+                            }
+                          }
+                          resizeArrowFn(
+                            element,
+                            p1,
+                            deltaX,
+                            deltaY,
+                            x,
+                            y,
+                            e.shiftKey,
+                          );
+                        } else {
+                          if (e.shiftKey) {
+                            if (isLinear) {
+                              const { width, height } = getPerfectElementSize(
+                                element.type,
+                                x - element.x,
+                                y - element.y,
+                              );
+                              element.width = width;
+                              element.height = height;
+                            } else {
+                              element.width += deltaX;
+                              element.height = element.width;
+                            }
                           } else {
                             element.width += deltaX;
-                            element.height = element.width;
+                            element.height += deltaY;
                           }
-                        } else {
-                          element.width += deltaX;
-                          element.height += deltaY;
                         }
                         break;
-                      case "n":
+                      case "n": {
                         element.height -= deltaY;
                         element.y += deltaY;
+
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+
+                          const points = [...element.points].sort(
+                            (a, b) => a[1] - b[1],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[1] -= deltaY / (len - i);
+                          }
+                        }
                         break;
-                      case "w":
+                      }
+                      case "w": {
                         element.width -= deltaX;
                         element.x += deltaX;
+
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[0] - b[0],
+                          );
+
+                          for (let i = 0; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[0] -= deltaX / (len - i);
+                          }
+                        }
                         break;
-                      case "s":
+                      }
+                      case "s": {
                         element.height += deltaY;
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[1] - b[1],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[1] += deltaY / (len - i);
+                          }
+                        }
                         break;
-                      case "e":
+                      }
+                      case "e": {
                         element.width += deltaX;
+                        if (element.points.length > 0) {
+                          const len = element.points.length;
+                          const points = [...element.points].sort(
+                            (a, b) => a[0] - b[0],
+                          );
+
+                          for (let i = 1; i < points.length; ++i) {
+                            const pnt = points[i];
+                            pnt[0] += deltaX / (len - i);
+                          }
+                        }
                         break;
+                      }
                     }
 
                     if (resizeHandle) {
@@ -1217,6 +1461,30 @@ export class App extends React.Component<any, AppState> {
 
                 draggingElement.width = width;
                 draggingElement.height = height;
+
+                if (this.state.elementType === "arrow") {
+                  draggingOccurred = true;
+                  const points = draggingElement.points;
+                  let dx = x - draggingElement.x;
+                  let dy = y - draggingElement.y;
+
+                  if (e.shiftKey && points.length === 2) {
+                    ({ width: dx, height: dy } = getPerfectElementSize(
+                      this.state.elementType,
+                      dx,
+                      dy,
+                    ));
+                  }
+
+                  if (points.length === 1) {
+                    points.push([dx, dy]);
+                  } else if (points.length > 1) {
+                    const pnt = points[points.length - 1];
+                    pnt[0] = dx;
+                    pnt[1] = dy;
+                  }
+                }
+
                 draggingElement.shape = null;
 
                 if (this.state.elementType === "selection") {
@@ -1240,15 +1508,33 @@ export class App extends React.Component<any, AppState> {
                 const {
                   draggingElement,
                   resizingElement,
+                  multiElement,
                   elementType,
                   elementLocked,
                 } = this.state;
 
+                resizeArrowFn = null;
                 lastMouseUp = null;
                 isHoldingMouseButton = false;
                 window.removeEventListener("mousemove", onMouseMove);
                 window.removeEventListener("mouseup", onMouseUp);
 
+                if (elementType === "arrow") {
+                  if (draggingElement!.points.length > 1) {
+                    history.resumeRecording();
+                  }
+                  if (!draggingOccurred && !multiElement) {
+                    this.setState({ multiElement: this.state.draggingElement });
+                  } else if (draggingOccurred && !multiElement) {
+                    this.state.draggingElement!.isSelected = true;
+                    this.setState({
+                      draggingElement: null,
+                      elementType: "selection",
+                    });
+                  }
+                  return;
+                }
+
                 if (
                   elementType !== "selection" &&
                   draggingElement &&
@@ -1328,9 +1614,15 @@ export class App extends React.Component<any, AppState> {
               window.addEventListener("mousemove", onMouseMove);
               window.addEventListener("mouseup", onMouseUp);
 
-              // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
-              history.skipRecording();
-              this.setState({});
+              if (
+                !this.state.multiElement ||
+                (this.state.multiElement &&
+                  this.state.multiElement.points.length < 2)
+              ) {
+                // We don't want to save history on mouseDown, only on mouseUp when it's fully configured
+                history.skipRecording();
+                this.setState({});
+              }
             }}
             onDoubleClick={e => {
               const { x, y } = viewportCoordsToSceneCoords(e, this.state);

+ 65 - 0
src/math.ts

@@ -1,3 +1,5 @@
+import { Point } from "roughjs/bin/geometry";
+
 // https://stackoverflow.com/a/6853926/232122
 export function distanceBetweenPointAndSegment(
   x: number,
@@ -52,3 +54,66 @@ export function rotate(
     (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
   ];
 }
+
+export const getPointOnAPath = (point: Point, path: Point[]) => {
+  const [px, py] = point;
+  const [start, ...other] = path;
+  let [lastX, lastY] = start;
+  let kLine: number = 0;
+  let idx: number = 0;
+
+  // if any item in the array is true, it means that a point is
+  // on some segment of a line based path
+  const retVal = other.some(([x2, y2], i) => {
+    // we always take a line when dealing with line segments
+    const x1 = lastX;
+    const y1 = lastY;
+
+    lastX = x2;
+    lastY = y2;
+
+    // if a point is not within the domain of the line segment
+    // it is not on the line segment
+    if (px < x1 || px > x2) {
+      return false;
+    }
+
+    // check if all points lie on the same line
+    // y1 = kx1 + b, y2 = kx2 + b
+    // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
+
+    // coefficient for the line (p0, p1)
+    const kL = (y2 - y1) / (x2 - x1);
+
+    // coefficient for the line segment (p0, point)
+    const kP1 = (py - y1) / (px - x1);
+
+    // coefficient for the line segment (point, p1)
+    const kP2 = (py - y2) / (px - x2);
+
+    // because we are basing both lines from the same starting point
+    // the only option for collinearity is having same coefficients
+
+    // using it for floating point comparisons
+    const epsilon = 0.3;
+
+    // if coefficient is more than an arbitrary epsilon,
+    // these lines are nor collinear
+    if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
+      return false;
+    }
+
+    // store the coefficient because we are goint to need it
+    kLine = kL;
+    idx = i;
+
+    return true;
+  });
+
+  // Return a coordinate that is always on the line segment
+  if (retVal === true) {
+    return { x: point[0], y: kLine * point[0], segment: idx };
+  }
+
+  return null;
+};

+ 8 - 3
src/renderer/renderElement.ts

@@ -7,6 +7,7 @@ import {
 } from "../element/bounds";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import { Drawable } from "roughjs/bin/core";
+import { Point } from "roughjs/bin/geometry";
 import { RoughSVG } from "roughjs/bin/svg";
 import { RoughGenerator } from "roughjs/bin/generator";
 import { SVG_NS } from "../utils";
@@ -89,18 +90,23 @@ function generateElement(
         );
         break;
       case "arrow": {
-        const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
+        const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
         const options = {
           stroke: element.strokeColor,
           strokeWidth: element.strokeWidth,
           roughness: element.roughness,
           seed: element.seed,
         };
+        // points array can be empty in the beginning, so it is important to add
+        // initial position to it
+        const points: Point[] = element.points.length
+          ? element.points
+          : [[0, 0]];
         element.shape = [
           //    \
           generator.line(x3, y3, x2, y2, options),
           // -----
-          generator.line(x1, y1, x2, y2, options),
+          generator.curve(points, options),
           //    /
           generator.line(x4, y4, x2, y2, options),
         ];
@@ -169,7 +175,6 @@ export function renderElement(
         context.fillStyle = fillStyle;
         context.font = font;
         context.globalAlpha = 1;
-        break;
       } else {
         throw new Error("Unimplemented type " + element.type);
       }

+ 19 - 8
src/renderer/renderScene.ts

@@ -107,9 +107,11 @@ export function renderScene(
 
     if (selectedElements.length === 1 && selectedElements[0].type !== "text") {
       const handlers = handlerRectangles(selectedElements[0], sceneState);
-      Object.values(handlers).forEach(handler => {
-        context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
-      });
+      Object.values(handlers)
+        .filter(handler => handler !== undefined)
+        .forEach(handler => {
+          context.strokeRect(handler[0], handler[1], handler[2], handler[3]);
+        });
     }
   }
 
@@ -149,11 +151,20 @@ function isVisibleElement(
   canvasHeight: number,
 ) {
   let [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
-  x1 += scrollX;
-  y1 += scrollY;
-  x2 += scrollX;
-  y2 += scrollY;
-  return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
+  if (element.type !== "arrow") {
+    x1 += scrollX;
+    y1 += scrollY;
+    x2 += scrollX;
+    y2 += scrollY;
+    return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight;
+  } else {
+    return (
+      x2 + scrollX >= 0 &&
+      x1 + scrollX <= canvasWidth &&
+      y2 + scrollY >= 0 &&
+      y1 + scrollY <= canvasHeight
+    );
+  }
 }
 
 // This should be only called for exporting purposes

+ 40 - 14
src/scene/data.ts

@@ -1,6 +1,6 @@
 import { ExcalidrawElement } from "../element/types";
 
-import { getDefaultAppState } from "../appState";
+import { getDefaultAppState, cleanAppStateForExport } from "../appState";
 
 import { AppState } from "../types";
 import { ExportType, PreviousScene } from "./types";
@@ -9,6 +9,7 @@ import nanoid from "nanoid";
 import { fileOpen, fileSave } from "browser-nativefs";
 import { getCommonBounds } from "../element";
 
+import { Point } from "roughjs/bin/geometry";
 import { t } from "../i18n";
 
 const LOCAL_STORAGE_KEY = "excalidraw";
@@ -24,7 +25,7 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
 
 interface DataState {
   elements: readonly ExcalidrawElement[];
-  appState: AppState;
+  appState: AppState | null;
   selectedId?: number;
 }
 
@@ -36,10 +37,9 @@ export function serializeAsJSON(
     {
       type: "excalidraw",
       version: 1,
-      appState: {
-        viewBackgroundColor: appState.viewBackgroundColor,
-      },
+      source: window.location.origin,
       elements: elements.map(({ shape, isSelected, ...el }) => el),
+      appState: cleanAppStateForExport(appState),
     },
     null,
     2,
@@ -118,9 +118,7 @@ export async function loadFromJSON() {
   }
   const { elements, appState } = updateAppState(contents);
   return new Promise<DataState>(resolve => {
-    resolve(
-      restore(elements, { ...appState, ...calculateScrollCenter(elements) }),
-    );
+    resolve(restore(elements, appState, { scrollToContent: true }));
   });
 }
 
@@ -175,7 +173,7 @@ export async function importFromBackend(id: string | null) {
       console.error(error);
     }
   }
-  return restore(elements, { ...appState, ...calculateScrollCenter(elements) });
+  return restore(elements, appState, { scrollToContent: true });
 }
 
 export async function exportCanvas(
@@ -259,10 +257,29 @@ export async function exportCanvas(
 
 function restore(
   savedElements: readonly ExcalidrawElement[],
-  savedState: AppState,
+  savedState: AppState | null,
+  opts?: { scrollToContent: boolean },
 ): DataState {
-  return {
-    elements: savedElements.map(element => ({
+  const elements = savedElements.map(element => {
+    let points: Point[] = [];
+    if (element.type === "arrow") {
+      if (Array.isArray(element.points)) {
+        // if point array is empty, add one point to the arrow
+        // this is used as fail safe to convert incoming data to a valid
+        // arrow. In the new arrow, width and height are not being usde
+        points = element.points.length > 0 ? element.points : [[0, 0]];
+      } else {
+        // convert old arrow type to a new one
+        // old arrow spec used width and height
+        // to determine the endpoints
+        points = [
+          [0, 0],
+          [element.width, element.height],
+        ];
+      }
+    }
+
+    return {
       ...element,
       id: element.id || nanoid(),
       fillStyle: element.fillStyle || "hachure",
@@ -272,7 +289,16 @@ function restore(
         element.opacity === null || element.opacity === undefined
           ? 100
           : element.opacity,
-    })),
+      points,
+    };
+  });
+
+  if (opts?.scrollToContent && savedState) {
+    savedState = { ...savedState, ...calculateScrollCenter(elements) };
+  }
+
+  return {
+    elements: elements,
     appState: savedState,
   };
 }
@@ -295,7 +321,7 @@ export function restoreFromLocalStorage() {
   let appState = null;
   if (savedState) {
     try {
-      appState = JSON.parse(savedState);
+      appState = JSON.parse(savedState) as AppState;
     } catch (e) {
       // Do nothing because appState is already null
     }

+ 1 - 0
src/types.ts

@@ -3,6 +3,7 @@ import { ExcalidrawElement } from "./element/types";
 export type AppState = {
   draggingElement: ExcalidrawElement | null;
   resizingElement: ExcalidrawElement | null;
+  multiElement: ExcalidrawElement | null;
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: ExcalidrawElement | null;

+ 6 - 0
src/utils.ts

@@ -103,3 +103,9 @@ export function removeSelection() {
 export function distance(x: number, y: number) {
   return Math.abs(x - y);
 }
+
+export function distance2d(x1: number, y1: number, x2: number, y2: number) {
+  const xd = x2 - x1;
+  const yd = y2 - y1;
+  return Math.sqrt(xd * xd + yd * yd);
+}