diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json new file mode 100644 index 00000000..f81e53a4 --- /dev/null +++ b/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" + } +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..308319dc --- /dev/null +++ b/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" + } +} diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json new file mode 100644 index 00000000..e636f217 --- /dev/null +++ b/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" + } +} diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json new file mode 100644 index 00000000..253a4f17 --- /dev/null +++ b/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" + } +} diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json new file mode 100644 index 00000000..4e65b006 --- /dev/null +++ b/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" + } +} diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json new file mode 100644 index 00000000..568effc3 --- /dev/null +++ b/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": "Фигуры" + } +} diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 5d58c9fb..e3a80917 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/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", diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx new file mode 100644 index 00000000..1a96248a --- /dev/null +++ b/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), +}; diff --git a/src/actions/index.ts b/src/actions/index.ts index 0b60a0fd..79db254f 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -23,6 +23,8 @@ export { actionClearCanvas, } from "./actionCanvas"; +export { actionFinalize } from "./actionFinalize"; + export { actionChangeProjectName, actionChangeExportBackground, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 3d25cf19..ace170d9 100644 --- a/src/actions/manager.tsx +++ b/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; diff --git a/src/actions/types.ts b/src/actions/types.ts index e07a572f..fb24c085 100644 --- a/src/actions/types.ts +++ b/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; diff --git a/src/appState.ts b/src/appState.ts index 8ca9fabb..e7a828e8 100644 --- a/src/appState.ts +++ b/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, + }; +} diff --git a/src/element/bounds.ts b/src/element/bounds.ts index d3d259bf..08428379 100644 --- a/src/element/bounds.ts +++ b/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) { diff --git a/src/element/collision.ts b/src/element/collision.ts index 3a4990eb..97965f02 100644 --- a/src/element/collision.ts +++ b/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; + }); +}; diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index ab5374ba..d202fd81 100644 --- a/src/element/handlerRectangles.ts +++ b/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; diff --git a/src/element/index.ts b/src/element/index.ts index 62ed2125..6a68a727 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -5,6 +5,7 @@ export { getDiamondPoints, getArrowPoints, getLinePoints, + getArrowAbsoluteBounds, } from "./bounds"; export { handlerRectangles } from "./handlerRectangles"; diff --git a/src/element/newElement.ts b/src/element/newElement.ts index f5ce32e6..812afb4c 100644 --- a/src/element/newElement.ts +++ b/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; } diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 0e8c98a2..532b9b94 100644 --- a/src/element/resizeTest.ts +++ b/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] && diff --git a/src/index.tsx b/src/index.tsx index 2323477b..0700ecaf 100644 --- a/src/index.tsx +++ b/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 { canvasOnlyActions: Array; 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 { }; 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 { } 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 { 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 { 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 { 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 { 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 { element.type === "line" || element.type === "arrow"; switch (resizeHandle) { case "nw": - element.width -= deltaX; - element.x += deltaX; + if ( + element.type === "arrow" && + element.points.length === 2 + ) { + const [, p1] = element.points; - if (e.shiftKey) { - if (isLinear) { - resizePerfectLineForNWHandler(element, x, y); - } else { - element.y += element.height - element.width; - element.height = element.width; + 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 { 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 { 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 { 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); diff --git a/src/math.ts b/src/math.ts index bbcbe17d..fae4d950 100644 --- a/src/math.ts +++ b/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; +}; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index cbe33110..0e9d175a 100644 --- a/src/renderer/renderElement.ts +++ b/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); } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 59d0a36d..d326817b 100644 --- a/src/renderer/renderScene.ts +++ b/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 diff --git a/src/scene/data.ts b/src/scene/data.ts index 7fa6a8df..436f0d1a 100644 --- a/src/scene/data.ts +++ b/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(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 } diff --git a/src/types.ts b/src/types.ts index 284c1d87..f85d2ebd 100644 --- a/src/types.ts +++ b/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; diff --git a/src/utils.ts b/src/utils.ts index 2d5a0269..74746b74 100644 --- a/src/utils.ts +++ b/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); +}