mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 02:44:50 +01:00 
			
		
		
		
	Reintroduce multi-point arrows and add migration for it (#635)
* Revert "Revert "Feature: Multi Point Arrows (#338)" (#634)"
This reverts commit 3d2e59bfed.
* 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>
			
			
This commit is contained in:
		
							
								
								
									
										67
									
								
								public/locales/de/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								public/locales/de/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								public/locales/en/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								public/locales/en/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								public/locales/es/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								public/locales/es/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								public/locales/fr/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								public/locales/fr/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								public/locales/pt/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								public/locales/pt/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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
									
								
								public/locales/ru/translation.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								public/locales/ru/translation.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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": "Фигуры" | ||||
|   } | ||||
| } | ||||
| @@ -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
									
								
								src/actions/actionFinalize.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/actions/actionFinalize.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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), | ||||
| }; | ||||
| @@ -23,6 +23,8 @@ export { | ||||
|   actionClearCanvas, | ||||
| } from "./actionCanvas"; | ||||
|  | ||||
| export { actionFinalize } from "./actionFinalize"; | ||||
|  | ||||
| export { | ||||
|   actionChangeProjectName, | ||||
|   actionChangeExportBackground, | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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,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, | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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; | ||||
|   }); | ||||
| }; | ||||
|   | ||||
| @@ -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,12 +90,59 @@ 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; | ||||
| } | ||||
|   | ||||
| @@ -5,6 +5,7 @@ export { | ||||
|   getDiamondPoints, | ||||
|   getArrowPoints, | ||||
|   getLinePoints, | ||||
|   getArrowAbsoluteBounds, | ||||
| } from "./bounds"; | ||||
|  | ||||
| export { handlerRectangles } from "./handlerRectangles"; | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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] && | ||||
|   | ||||
							
								
								
									
										334
									
								
								src/index.tsx
									
									
									
									
									
								
							
							
						
						
									
										334
									
								
								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,10 +1022,27 @@ 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 }); | ||||
|                   this.setState({ | ||||
|                     draggingElement: element, | ||||
|                   }); | ||||
|                 } | ||||
|               } else { | ||||
|                 elements = [...elements, element]; | ||||
|                 this.setState({ multiElement: null, 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,6 +1169,29 @@ export class App extends React.Component<any, AppState> { | ||||
|                       element.type === "line" || element.type === "arrow"; | ||||
|                     switch (resizeHandle) { | ||||
|                       case "nw": | ||||
|                         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.width -= deltaX; | ||||
|                           element.x += deltaX; | ||||
|  | ||||
| @@ -1083,8 +1206,31 @@ export class App extends React.Component<any, AppState> { | ||||
|                             element.height -= deltaY; | ||||
|                             element.y += deltaY; | ||||
|                           } | ||||
|                         } | ||||
|                         break; | ||||
|                       case "ne": | ||||
|                         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.width += deltaX; | ||||
|                           if (e.shiftKey) { | ||||
|                             element.y += element.height - element.width; | ||||
| @@ -1093,8 +1239,31 @@ export class App extends React.Component<any, AppState> { | ||||
|                             element.height -= deltaY; | ||||
|                             element.y += deltaY; | ||||
|                           } | ||||
|                         } | ||||
|                         break; | ||||
|                       case "sw": | ||||
|                         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.width -= deltaX; | ||||
|                           element.x += deltaX; | ||||
|                           if (e.shiftKey) { | ||||
| @@ -1102,8 +1271,31 @@ export class App extends React.Component<any, AppState> { | ||||
|                           } else { | ||||
|                             element.height += deltaY; | ||||
|                           } | ||||
|                         } | ||||
|                         break; | ||||
|                       case "se": | ||||
|                         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( | ||||
| @@ -1121,22 +1313,74 @@ export class App extends React.Component<any, AppState> { | ||||
|                             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) { | ||||
|                       resizeHandle = normalizeResizeHandle( | ||||
| @@ -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); | ||||
|  | ||||
|               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
									
								
								src/math.ts
									
									
									
									
									
								
							
							
						
						
									
										65
									
								
								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; | ||||
| }; | ||||
|   | ||||
| @@ -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); | ||||
|       } | ||||
|   | ||||
| @@ -107,7 +107,9 @@ export function renderScene( | ||||
|  | ||||
|     if (selectedElements.length === 1 && selectedElements[0].type !== "text") { | ||||
|       const handlers = handlerRectangles(selectedElements[0], sceneState); | ||||
|       Object.values(handlers).forEach(handler => { | ||||
|       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); | ||||
|   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 | ||||
|   | ||||
| @@ -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 { | ||||
|   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 { | ||||
|     elements: savedElements.map(element => ({ | ||||
|       ...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 | ||||
|     } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Gasim Gasimzada
					Gasim Gasimzada