From a4f05339aa0cbaecdb619f3de11f5e40d8c37e3e Mon Sep 17 00:00:00 2001 From: Rounik Prashar <56750020+irounik@users.noreply.github.com> Date: Wed, 24 May 2023 02:07:19 +0530 Subject: [PATCH 01/16] fix: Library dropdown visibility issue for mobile (#6613) Fix: Library dropdown visibility issue for mobile Co-authored-by: Rounik Prashar --- src/components/dropdownMenu/DropdownMenu.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/dropdownMenu/DropdownMenu.scss b/src/components/dropdownMenu/DropdownMenu.scss index ff94f4920b..6e628736fd 100644 --- a/src/components/dropdownMenu/DropdownMenu.scss +++ b/src/components/dropdownMenu/DropdownMenu.scss @@ -11,8 +11,6 @@ top: auto; left: 0; width: 100%; - display: flex; - flex-direction: column; row-gap: 0.75rem; .dropdown-menu-container { From 7340c70a0697bcdafe3eebab33ffecb116c8bc5f Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Wed, 24 May 2023 16:40:20 +0200 Subject: [PATCH 02/16] perf: improve rendering performance for Library (#6587) * perf: improve rendering performance for Library * fix: return onDrag and onToggle functionality to Library Items * perf: cache exportToSvg output * fix: lint warning * fix: add onClick handler into LibraryUnit * feat: better spinner * fix: useCallback for getInsertedElements to fix linter error * feat: different batch size when svgs are cached * fix: library items alignment in row * feat: skeleton instead of spinner * fix: remove unused variables * feat: use css vars instead of hadcoded colors * feat: reverting skeleton, removing spinner * cleanup and unrelated refactor * change ROWS_RENDERED_PER_BATCH to 6 --------- Co-authored-by: dwelle --- src/components/LibraryMenu.tsx | 17 +- src/components/LibraryMenuItems.tsx | 246 ++++++++++---------------- src/components/LibraryMenuSection.tsx | 110 ++++++++++++ src/components/LibraryUnit.tsx | 27 +-- src/components/Spinner.scss | 1 + src/components/Spinner.tsx | 15 +- src/hooks/useLibraryItemSvg.ts | 59 ++++++ 7 files changed, 295 insertions(+), 180 deletions(-) create mode 100644 src/components/LibraryMenuSection.tsx create mode 100644 src/hooks/useLibraryItemSvg.ts diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index 0101d0cb50..a49f319709 100644 --- a/src/components/LibraryMenu.tsx +++ b/src/components/LibraryMenu.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from "react"; +import React, { useCallback } from "react"; import Library, { distributeLibraryItemsOnSquareGrid, libraryItemsAtom, @@ -43,8 +43,6 @@ export const LibraryMenuContent = ({ library, id, appState, - selectedItems, - onSelectItems, }: { pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; @@ -54,8 +52,6 @@ export const LibraryMenuContent = ({ library: Library; id: string; appState: UIAppState; - selectedItems: LibraryItem["id"][]; - onSelectItems: (id: LibraryItem["id"][]) => void; }) => { const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); @@ -113,8 +109,6 @@ export const LibraryMenuContent = ({ } onInsertLibraryItems={onInsertLibraryItems} pendingElements={pendingElements} - selectedItems={selectedItems} - onSelectItems={onSelectItems} id={id} libraryReturnUrl={libraryReturnUrl} theme={appState.theme} @@ -143,9 +137,8 @@ export const LibraryMenu = () => { const setAppState = useExcalidrawSetAppState(); const elements = useExcalidrawElements(); - const [selectedItems, setSelectedItems] = useState([]); - - const deselectItems = useCallback(() => { + const onAddToLibrary = useCallback(() => { + // deselect canvas elements setAppState({ selectedElementIds: {}, selectedGroupIds: {}, @@ -158,14 +151,12 @@ export const LibraryMenu = () => { onInsertLibraryItems={(libraryItems) => { onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); }} - onAddToLibrary={deselectItems} + onAddToLibrary={onAddToLibrary} setAppState={setAppState} libraryReturnUrl={appProps.libraryReturnUrl} library={library} id={id} appState={appState} - selectedItems={selectedItems} - onSelectItems={setSelectedItems} /> ); }; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 0f4dda589a..74f295cd4f 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,6 +1,5 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { serializeLibraryAsJSON } from "../data/json"; -import { ExcalidrawElement, NonDeleted } from "../element/types"; import { t } from "../i18n"; import { ExcalidrawProps, @@ -8,27 +7,23 @@ import { LibraryItems, UIAppState, } from "../types"; -import { arrayToMap, chunk } from "../utils"; -import { LibraryUnit } from "./LibraryUnit"; +import { arrayToMap } from "../utils"; import Stack from "./Stack"; import { MIME_TYPES } from "../constants"; import Spinner from "./Spinner"; import { duplicateElements } from "../element/newElement"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; +import LibraryMenuSection from "./LibraryMenuSection"; import "./LibraryMenuItems.scss"; -const CELLS_PER_ROW = 4; - -const LibraryMenuItems = ({ +export default function LibraryMenuItems({ isLoading, libraryItems, onAddToLibrary, onInsertLibraryItems, pendingElements, - selectedItems, - onSelectItems, theme, id, libraryReturnUrl, @@ -38,12 +33,26 @@ const LibraryMenuItems = ({ pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; onAddToLibrary: (elements: LibraryItem["elements"]) => void; - selectedItems: LibraryItem["id"][]; - onSelectItems: (id: LibraryItem["id"][]) => void; libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; theme: UIAppState["theme"]; id: string; -}) => { +}) { + const [selectedItems, setSelectedItems] = useState([]); + + const unpublishedItems = libraryItems.filter( + (item) => item.status !== "published", + ); + const publishedItems = libraryItems.filter( + (item) => item.status === "published", + ); + + const showBtn = !libraryItems.length && !pendingElements.length; + + const isLibraryEmpty = + !pendingElements.length && + !unpublishedItems.length && + !publishedItems.length; + const [lastSelectedItem, setLastSelectedItem] = useState< LibraryItem["id"] | null >(null); @@ -64,7 +73,7 @@ const LibraryMenuItems = ({ const rangeEnd = orderedItems.findIndex((item) => item.id === id); if (rangeStart === -1 || rangeEnd === -1) { - onSelectItems([...selectedItems, id]); + setSelectedItems([...selectedItems, id]); return; } @@ -82,136 +91,69 @@ const LibraryMenuItems = ({ [], ); - onSelectItems(nextSelectedIds); + setSelectedItems(nextSelectedIds); } else { - onSelectItems([...selectedItems, id]); + setSelectedItems([...selectedItems, id]); } setLastSelectedItem(id); } else { setLastSelectedItem(null); - onSelectItems(selectedItems.filter((_id) => _id !== id)); + setSelectedItems(selectedItems.filter((_id) => _id !== id)); } }; - const getInsertedElements = (id: string) => { - let targetElements; - if (selectedItems.includes(id)) { - targetElements = libraryItems.filter((item) => - selectedItems.includes(item.id), - ); - } else { - targetElements = libraryItems.filter((item) => item.id === id); - } - return targetElements.map((item) => { - return { - ...item, - // duplicate each library item before inserting on canvas to confine - // ids and bindings to each library item. See #6465 - elements: duplicateElements(item.elements, { randomizeSeed: true }), - }; - }); - }; + const getInsertedElements = useCallback( + (id: string) => { + let targetElements; + if (selectedItems.includes(id)) { + targetElements = libraryItems.filter((item) => + selectedItems.includes(item.id), + ); + } else { + targetElements = libraryItems.filter((item) => item.id === id); + } + return targetElements.map((item) => { + return { + ...item, + // duplicate each library item before inserting on canvas to confine + // ids and bindings to each library item. See #6465 + elements: duplicateElements(item.elements, { randomizeSeed: true }), + }; + }); + }, + [libraryItems, selectedItems], + ); - const createLibraryItemCompo = (params: { - item: - | LibraryItem - | /* pending library item */ { - id: null; - elements: readonly NonDeleted[]; - } - | null; - onClick?: () => void; - key: string; - }) => { - return ( - - {})} - id={params.item?.id || null} - selected={!!params.item?.id && selectedItems.includes(params.item.id)} - onToggle={onItemSelectToggle} - onDrag={(id, event) => { - event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), - ); - }} - /> - + const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => { + event.dataTransfer.setData( + MIME_TYPES.excalidrawlib, + serializeLibraryAsJSON(getInsertedElements(id)), ); }; - const renderLibrarySection = ( - items: ( - | LibraryItem - | /* pending library item */ { - id: null; - elements: readonly NonDeleted[]; - } - )[], - ) => { - const _items = items.map((item) => { - if (item.id) { - return createLibraryItemCompo({ - item, - onClick: () => onInsertLibraryItems(getInsertedElements(item.id)), - key: item.id, - }); - } - return createLibraryItemCompo({ - key: "__pending__item__", - item, - onClick: () => onAddToLibrary(pendingElements), - }); - }); - - // ensure we render all empty cells if no items are present - let rows = chunk(_items, CELLS_PER_ROW); - if (!rows.length) { - rows = [[]]; + const isItemSelected = (id: LibraryItem["id"] | null) => { + if (!id) { + return false; } - return rows.map((rowItems, index, rows) => { - if (index === rows.length - 1) { - // pad row with empty cells - rowItems = rowItems.concat( - new Array(CELLS_PER_ROW - rowItems.length) - .fill(null) - .map((_, index) => { - return createLibraryItemCompo({ - key: `empty_${index}`, - item: null, - }); - }), - ); - } - return ( - - {rowItems} - - ); - }); + return selectedItems.includes(id); }; - const unpublishedItems = libraryItems.filter( - (item) => item.status !== "published", + const onItemClick = useCallback( + (id: LibraryItem["id"] | null) => { + if (!id) { + onAddToLibrary(pendingElements); + } else { + onInsertLibraryItems(getInsertedElements(id)); + } + }, + [ + getInsertedElements, + onAddToLibrary, + onInsertLibraryItems, + pendingElements, + ], ); - const publishedItems = libraryItems.filter( - (item) => item.status === "published", - ); - - const showBtn = !libraryItems.length && !pendingElements.length; - - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; return (
)} @@ -258,28 +200,32 @@ const LibraryMenuItems = ({
)} -
- {!pendingElements.length && !unpublishedItems.length ? ( -
-
- {t("library.noItems")} -
-
- {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
+ {!pendingElements.length && !unpublishedItems.length ? ( +
+
+ {t("library.noItems")}
- ) : ( - renderLibrarySection([ +
+ {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
+
+ ) : ( + + ]} + onItemSelectToggle={onItemSelectToggle} + onItemDrag={onItemDrag} + onClick={onItemClick} + isItemSelected={isItemSelected} + /> + )} <> @@ -291,7 +237,13 @@ const LibraryMenuItems = ({
)} {publishedItems.length > 0 ? ( - renderLibrarySection(publishedItems) + ) : unpublishedItems.length > 0 ? (
)}
); -}; - -export default LibraryMenuItems; +} diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx new file mode 100644 index 0000000000..d1af854813 --- /dev/null +++ b/src/components/LibraryMenuSection.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useMemo, useState, useTransition } from "react"; +import { LibraryUnit } from "./LibraryUnit"; +import { LibraryItem } from "../types"; +import Stack from "./Stack"; +import clsx from "clsx"; +import { ExcalidrawElement, NonDeleted } from "../element/types"; +import { useAtom } from "jotai"; +import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; + +const ITEMS_PER_ROW = 4; +const ROWS_RENDERED_PER_BATCH = 6; +const CACHED_ROWS_RENDERED_PER_BATCH = 16; + +type LibraryOrPendingItem = ( + | LibraryItem + | /* pending library item */ { + id: null; + elements: readonly NonDeleted[]; + } +)[]; + +interface Props { + items: LibraryOrPendingItem; + onClick: (id: LibraryItem["id"] | null) => void; + onItemSelectToggle: (id: LibraryItem["id"], event: React.MouseEvent) => void; + onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void; + isItemSelected: (id: LibraryItem["id"] | null) => boolean; +} + +function LibraryRow({ + items, + onItemSelectToggle, + onItemDrag, + isItemSelected, + onClick, +}: Props) { + return ( + + {items.map((item) => ( + + + + ))} + + ); +} + +const EmptyLibraryRow = () => ( + + +
+ + +); + +function LibraryMenuSection({ + items, + onItemSelectToggle, + onItemDrag, + isItemSelected, + onClick, +}: Props) { + const rows = Math.ceil(items.length / ITEMS_PER_ROW); + const [, startTransition] = useTransition(); + const [index, setIndex] = useState(0); + const [svgCache] = useAtom(libraryItemSvgsCache); + + const rowsRenderedPerBatch = useMemo(() => { + return svgCache.size === 0 + ? ROWS_RENDERED_PER_BATCH + : CACHED_ROWS_RENDERED_PER_BATCH; + }, [svgCache]); + + useEffect(() => { + if (index < rows) { + startTransition(() => { + setIndex(index + rowsRenderedPerBatch); + }); + } + }, [index, rows, startTransition, rowsRenderedPerBatch]); + + return ( + <> + {Array.from({ length: rows }).map((_, i) => + i < index ? ( + + ) : ( + + ), + )} + + ); +} + +export default LibraryMenuSection; diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 7e8181d7b4..68fdec1430 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,12 +1,11 @@ import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; import { useDevice } from "../components/App"; -import { exportToSvg } from "../packages/utils"; import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; -import { COLOR_PALETTE } from "../colors"; +import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; export const LibraryUnit = ({ id, @@ -20,38 +19,30 @@ export const LibraryUnit = ({ id: LibraryItem["id"] | /** for pending item */ null; elements?: LibraryItem["elements"]; isPending?: boolean; - onClick: () => void; + onClick: (id: LibraryItem["id"] | null) => void; selected: boolean; onToggle: (id: string, event: React.MouseEvent) => void; onDrag: (id: string, event: React.DragEvent) => void; }) => { const ref = useRef(null); + const svg = useLibraryItemSvg(id, elements); + useEffect(() => { const node = ref.current; + if (!node) { return; } - (async () => { - if (!elements) { - return; - } - const svg = await exportToSvg({ - elements, - appState: { - exportBackground: false, - viewBackgroundColor: COLOR_PALETTE.white, - }, - files: null, - }); + if (svg) { svg.querySelector(".style-fonts")?.remove(); node.innerHTML = svg.outerHTML; - })(); + } return () => { node.innerHTML = ""; }; - }, [elements]); + }, [elements, svg]); const [isHovered, setIsHovered] = useState(false); const isMobile = useDevice().isMobile; @@ -81,7 +72,7 @@ export const LibraryUnit = ({ if (id && event.shiftKey) { onToggle(id, event); } else { - onClick(); + onClick(id); } } : undefined diff --git a/src/components/Spinner.scss b/src/components/Spinner.scss index fd6fd50e8c..e2d90f8811 100644 --- a/src/components/Spinner.scss +++ b/src/components/Spinner.scss @@ -15,6 +15,7 @@ $duration: 1.6s; svg { animation: rotate $duration linear infinite; + animation-delay: var(--spinner-delay); transform-origin: center center; } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index c4edb65afe..8bc1e59114 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -5,13 +5,26 @@ import "./Spinner.scss"; const Spinner = ({ size = "1em", circleWidth = 8, + synchronized = false, }: { size?: string | number; circleWidth?: number; + synchronized?: boolean; }) => { + const mountTime = React.useRef(Date.now()); + const mountDelay = -(mountTime.current % 1600); + return (
- + >( + new Map(), +); + +const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { + return await exportToSvg({ + elements, + appState: { + exportBackground: false, + viewBackgroundColor: COLOR_PALETTE.white, + }, + files: null, + }); +}; + +export const useLibraryItemSvg = ( + id: LibraryItem["id"] | null, + elements: LibraryItem["elements"] | undefined, +): SVGSVGElement | undefined => { + const [svgCache, setSvgCache] = useAtom(libraryItemSvgsCache); + const [svg, setSvg] = useState(); + + useEffect(() => { + if (elements) { + if (id) { + // Try to load cached svg + const cachedSvg = svgCache.get(id); + + if (cachedSvg) { + setSvg(cachedSvg); + } else { + // When there is no svg in cache export it and save to cache + (async () => { + const exportedSvg = await exportLibraryItemToSvg(elements); + + if (exportedSvg) { + setSvgCache(svgCache.set(id, exportedSvg)); + setSvg(exportedSvg); + } + })(); + } + } else { + // When we have no id (usualy selected items from canvas) just export the svg + (async () => { + const exportedSvg = await exportLibraryItemToSvg(elements); + setSvg(exportedSvg); + })(); + } + } + }, [id, elements, svgCache, setSvgCache, setSvg]); + + return svg; +}; From fecbde3f5cff17783e6d7fa355b2f7890d2fcc9f Mon Sep 17 00:00:00 2001 From: Excalidraw Bot <77840495+excalibot@users.noreply.github.com> Date: Wed, 24 May 2023 16:50:11 +0200 Subject: [PATCH 03/16] chore: Update translations from Crowdin (#6598) * New translations en.json (Greek) * New translations en.json (Slovenian) * New translations en.json (Portuguese, Brazilian) * Auto commit: Calculate translation coverage * New translations en.json (German) * Auto commit: Calculate translation coverage * New translations en.json (Korean) * New translations en.json (Chinese Traditional) * Auto commit: Calculate translation coverage * New translations en.json (Norwegian Bokmal) * Auto commit: Calculate translation coverage * New translations en.json (Indonesian) * New translations en.json (Indonesian) * Auto commit: Calculate translation coverage * New translations en.json (Indonesian) * Auto commit: Calculate translation coverage * New translations en.json (Indonesian) * Auto commit: Calculate translation coverage * New translations en.json (Chinese Simplified) * Auto commit: Calculate translation coverage * New translations en.json (Romanian) * Auto commit: Calculate translation coverage * New translations en.json (Romanian) * Auto commit: Calculate translation coverage * New translations en.json (Russian) * Auto commit: Calculate translation coverage * New translations en.json (Slovak) * Auto commit: Calculate translation coverage * New translations en.json (Italian) * Auto commit: Calculate translation coverage * New translations en.json (Italian) * Auto commit: Calculate translation coverage * New translations en.json (Kurdish) * New translations en.json (Swedish) * Auto commit: Calculate translation coverage * New translations en.json (Portuguese) * Auto commit: Calculate translation coverage * New translations en.json (Portuguese) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage * New translations en.json (Khmer) * Auto commit: Calculate translation coverage --- src/locales/de-DE.json | 38 +- src/locales/el-GR.json | 38 +- src/locales/id-ID.json | 58 +-- src/locales/it-IT.json | 40 +- src/locales/km-KH.json | 718 +++++++++++++++++------------------ src/locales/ko-KR.json | 38 +- src/locales/ku-TR.json | 4 +- src/locales/nb-NO.json | 38 +- src/locales/percentages.json | 32 +- src/locales/pt-BR.json | 38 +- src/locales/pt-PT.json | 28 +- src/locales/ro-RO.json | 38 +- src/locales/ru-RU.json | 38 +- src/locales/sk-SK.json | 38 +- src/locales/sl-SI.json | 38 +- src/locales/sv-SE.json | 38 +- src/locales/zh-CN.json | 38 +- src/locales/zh-TW.json | 38 +- 18 files changed, 668 insertions(+), 668 deletions(-) diff --git a/src/locales/de-DE.json b/src/locales/de-DE.json index 8e55185697..44a748c4dd 100644 --- a/src/locales/de-DE.json +++ b/src/locales/de-DE.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Transparent", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Schwarz", + "white": "Weiß", + "red": "Rot", + "pink": "Pink", + "grape": "Traube", + "violet": "Violett", + "gray": "Grau", + "blue": "Blau", + "cyan": "Cyan", + "teal": "Blaugrün", + "green": "Grün", + "yellow": "Gelb", + "orange": "Orange", + "bronze": "Bronze" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Beliebteste benutzerdefinierte Farben", + "colors": "Farben", + "shades": "Schattierungen", + "hexCode": "Hex-Code", + "noShades": "Keine Schattierungen für diese Farbe verfügbar" } } diff --git a/src/locales/el-GR.json b/src/locales/el-GR.json index e1008e05ca..638254cd0d 100644 --- a/src/locales/el-GR.json +++ b/src/locales/el-GR.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Διαφανές", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Μαύρο", + "white": "Λευκό", + "red": "Κόκκινο", + "pink": "Ροζ", + "grape": "Σταφυλί", + "violet": "Βιολετί", + "gray": "Γκρι", + "blue": "Μπλε", + "cyan": "Κυανό", + "teal": "Τιρκουάζ", + "green": "Πράσινο", + "yellow": "Κίτρινο", + "orange": "Πορτοκαλί", + "bronze": "Χαλκινο" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Πιο χρησιμοποιούμενα χρώματα", + "colors": "Χρώματα", + "shades": "Αποχρώσεις", + "hexCode": "Κωδικός Hex", + "noShades": "Δεν υπάρχουν διαθέσιμες αποχρώσεις για αυτό το χρώμα" } } diff --git a/src/locales/id-ID.json b/src/locales/id-ID.json index 37ecf6a18e..84abeef739 100644 --- a/src/locales/id-ID.json +++ b/src/locales/id-ID.json @@ -54,7 +54,7 @@ "veryLarge": "Sangat besar", "solid": "Padat", "hachure": "Garis-garis", - "zigzag": "", + "zigzag": "Zigzag", "crossHatch": "Asiran silang", "thin": "Lembut", "bold": "Tebal", @@ -111,7 +111,7 @@ "increaseFontSize": "Besarkan ukuran font", "unbindText": "Lepas teks", "bindText": "Kunci teks ke kontainer", - "createContainerFromText": "", + "createContainerFromText": "Bungkus teks dalam kontainer", "link": { "edit": "Edit tautan", "create": "Buat tautan", @@ -208,10 +208,10 @@ "collabSaveFailed": "Tidak dapat menyimpan ke dalam basis data server. Jika masih berlanjut, Anda sebaiknya simpan berkas Anda secara lokal untuk memastikan pekerjaan Anda tidak hilang.", "collabSaveFailed_sizeExceeded": "Tidak dapat menyimpan ke dalam basis data server, tampaknya ukuran kanvas terlalu besar. Anda sebaiknya simpan berkas Anda secara lokal untuk memastikan pekerjaan Anda tidak hilang.", "brave_measure_text_error": { - "line1": "", - "line2": "", - "line3": "", - "line4": "" + "line1": "Sepertinya Anda menggunkan peramban Brave dengan pengaturan Blokir Fingerprinting yang Agresif diaktifkan.", + "line2": "Ini dapat membuat Elemen Teks dalam gambar mu.", + "line3": "Kami sangat menyarankan mematikan pengaturan ini. Anda dapat mengikuti langkah-langkah ini untuk melakukannya.", + "line4": "Jika mematikan pengaturan ini tidak membenarkan tampilan elemen teks, mohon buka\nisu di GitHub kami, atau chat kami di Discord" } }, "toolBar": { @@ -229,7 +229,7 @@ "penMode": "Mode pena - mencegah sentuhan", "link": "Tambah/Perbarui tautan untuk bentuk yang dipilih", "eraser": "Penghapus", - "hand": "" + "hand": "Tangan (alat panning)" }, "headings": { "canvasActions": "Opsi Kanvas", @@ -237,7 +237,7 @@ "shapes": "Bentuk" }, "hints": { - "canvasPanning": "", + "canvasPanning": "Untuk memindahkan kanvas, tekan roda mouse atau spacebar sambil menyeret, atau menggunakan alat tangan", "linearElement": "Klik untuk memulai banyak poin, seret untuk satu baris", "freeDraw": "Klik dan seret, lepaskan jika Anda selesai", "text": "Tip: Anda juga dapat menambahkan teks dengan klik ganda di mana saja dengan alat pemilihan", @@ -306,8 +306,8 @@ "doubleClick": "klik-ganda", "drag": "seret", "editor": "Editor", - "editLineArrowPoints": "", - "editText": "", + "editLineArrowPoints": "Edit titik garis/panah", + "editText": "Edit teks / tambah label", "github": "Menemukan masalah? Kirimkan", "howto": "Ikuti panduan kami", "or": "atau", @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Transparan", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Hitam", + "white": "Putih", + "red": "Merah", + "pink": "Pink", + "grape": "Ungu", + "violet": "Violet", + "gray": "Abu-abu", + "blue": "Biru", + "cyan": "Cyan", + "teal": "Teal", + "green": "Hijau", + "yellow": "Kuning", + "orange": "Jingga", + "bronze": "Tembaga" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Warna yang sering dipakai", + "colors": "Warna", + "shades": "Nuansa", + "hexCode": "Kode hexa", + "noShades": "Tidak ada nuansa untuk warna ini" } } diff --git a/src/locales/it-IT.json b/src/locales/it-IT.json index d8e8afd5a0..775cced8fa 100644 --- a/src/locales/it-IT.json +++ b/src/locales/it-IT.json @@ -111,7 +111,7 @@ "increaseFontSize": "Aumenta la dimensione dei caratteri", "unbindText": "Scollega testo", "bindText": "Associa il testo al container", - "createContainerFromText": "", + "createContainerFromText": "Avvolgi il testo in un container", "link": { "edit": "Modifica link", "create": "Crea link", @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Trasparente", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Nero", + "white": "Bianco", + "red": "Rosso", + "pink": "Rosa", + "grape": "Uva", + "violet": "Viola", + "gray": "Grigio", + "blue": "Blu", + "cyan": "Ciano", + "teal": "Verde acqua", + "green": "Verde", + "yellow": "Giallo", + "orange": "Arancio", + "bronze": "Bronzo" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Colori personalizzati più utilizzati", + "colors": "Colori", + "shades": "Sfumature", + "hexCode": "Codice esadecimale", + "noShades": "Nessuna sfumatura disponibile per questo colore" } } diff --git a/src/locales/km-KH.json b/src/locales/km-KH.json index a51c437a20..d49abb45bc 100644 --- a/src/locales/km-KH.json +++ b/src/locales/km-KH.json @@ -1,414 +1,414 @@ { "labels": { - "paste": "", - "pasteAsPlaintext": "", - "pasteCharts": "", - "selectAll": "", - "multiSelect": "", - "moveCanvas": "", - "cut": "", - "copy": "", - "copyAsPng": "", - "copyAsSvg": "", - "copyText": "", - "bringForward": "", - "sendToBack": "", - "bringToFront": "", - "sendBackward": "", - "delete": "", - "copyStyles": "", - "pasteStyles": "", - "stroke": "", - "background": "", - "fill": "", - "strokeWidth": "", - "strokeStyle": "", - "strokeStyle_solid": "", - "strokeStyle_dashed": "", - "strokeStyle_dotted": "", - "sloppiness": "", - "opacity": "", - "textAlign": "", - "edges": "", - "sharp": "", - "round": "", - "arrowheads": "", - "arrowhead_none": "", - "arrowhead_arrow": "", - "arrowhead_bar": "", - "arrowhead_dot": "", - "arrowhead_triangle": "", - "fontSize": "", - "fontFamily": "", - "onlySelected": "", - "withBackground": "", - "exportEmbedScene": "", - "exportEmbedScene_details": "", - "addWatermark": "", - "handDrawn": "", - "normal": "", - "code": "", - "small": "", - "medium": "", - "large": "", - "veryLarge": "", - "solid": "", - "hachure": "", - "zigzag": "", - "crossHatch": "", - "thin": "", - "bold": "", - "left": "", - "center": "", - "right": "", - "extraBold": "", - "architect": "", - "artist": "", - "cartoonist": "", - "fileTitle": "", - "colorPicker": "", - "canvasColors": "", - "canvasBackground": "", - "drawingCanvas": "", - "layers": "", - "actions": "", - "language": "", - "liveCollaboration": "", - "duplicateSelection": "", - "untitled": "", - "name": "", - "yourName": "", - "madeWithExcalidraw": "", - "group": "", - "ungroup": "", - "collaborators": "", - "showGrid": "", - "addToLibrary": "", - "removeFromLibrary": "", - "libraryLoadingMessage": "", - "libraries": "", - "loadingScene": "", - "align": "", - "alignTop": "", - "alignBottom": "", - "alignLeft": "", - "alignRight": "", - "centerVertically": "", - "centerHorizontally": "", - "distributeHorizontally": "", - "distributeVertically": "", - "flipHorizontal": "", - "flipVertical": "", - "viewMode": "", - "toggleExportColorScheme": "", - "share": "", - "showStroke": "", - "showBackground": "", - "toggleTheme": "", - "personalLib": "", - "excalidrawLib": "", - "decreaseFontSize": "", - "increaseFontSize": "", - "unbindText": "", - "bindText": "", - "createContainerFromText": "", + "paste": "បិទភ្ជាប់", + "pasteAsPlaintext": "បិទភ្ជាប់ជាអត្ថបទធម្មតា", + "pasteCharts": "បិទភ្ជាប់តារាង", + "selectAll": "ជ្រើសរើស​ទាំងអស់", + "multiSelect": "បន្ថែមធាតុទៅលើការជ្រើសរើស", + "moveCanvas": "ផ្លាស់ទីបាវ", + "cut": "កាត់", + "copy": "ចម្លង", + "copyAsPng": "ចម្លងទៅក្តារតម្បៀតខ្ទាស់ជា​ PNG", + "copyAsSvg": "ចម្លងទៅក្តារតម្បៀតខ្ទាស់ជា​ SVG", + "copyText": "ចម្លងទៅក្តារតម្បៀតខ្ទាស់ជា​អត្ថបទ", + "bringForward": "នាំយកទៅលើ", + "sendToBack": "នាំយកទៅក្រោយបង្អស់", + "bringToFront": "នាំយកទៅលើបង្អស់", + "sendBackward": "នាំយកទៅក្រោយ", + "delete": "លុប", + "copyStyles": "ចម្លងរចនាប័ទ្ម", + "pasteStyles": "បិទភ្ជាប់រចនាប័ទ្ម", + "stroke": "ខ្វាច់", + "background": "ផ្ទៃខាងក្រោយ", + "fill": "បំពេញ", + "strokeWidth": "ទទឹងខ្វាច់", + "strokeStyle": "រចនាប័ទ្មរបស់ខ្វាច់", + "strokeStyle_solid": "តាន់", + "strokeStyle_dashed": "ដាច់ៗ", + "strokeStyle_dotted": "ចំណុចៗ", + "sloppiness": "រចនាប័ទ្មបន្ទាត់", + "opacity": "ភាពច្បាស់", + "textAlign": "តម្រឹមអត្ថបទ", + "edges": "គែម", + "sharp": "មុត", + "round": "រាងមូល", + "arrowheads": "ក្បាលព្រួញ", + "arrowhead_none": "គ្មាន", + "arrowhead_arrow": "ព្រួញ", + "arrowhead_bar": "របារ", + "arrowhead_dot": "ចំណុច", + "arrowhead_triangle": "ត្រីកោណ", + "fontSize": "ទំហំពុម្ពអក្សរ", + "fontFamily": "ក្រុម​ពុម្ពអក្សរ", + "onlySelected": "យកផ្នែកដែលត្រូវបានជ្រើសរើស​ប៉ុណ្ណោះ", + "withBackground": "ផ្ទៃខាងក្រោយ", + "exportEmbedScene": "ស៊ីនដែលត្រូវបានបង្កប់", + "exportEmbedScene_details": "ទិន្នន័យរបស់ស៊ីននឹងត្រូវបានរក្សាទុកទៅក្នុងឯកសារ PNG​ ឬ SVG ដូច្នេះស៊ីនអាចស្ដារឡើងវិញពីឯកសារនេះ ប៉ុន្តែទំហំឯកសារដែលនឹងត្រូវបាននាំចេញនេះនឹងកាន់តែធំ។", + "addWatermark": "បន្ថែមវ៉ាត់ធើម៉ាក \"Made with Excalidraw\"", + "handDrawn": "គូរដោយដៃ", + "normal": "ធម្មតា", + "code": "កូដ", + "small": "តូច", + "medium": "មធ្យម", + "large": "ធំ", + "veryLarge": "ធំខ្លាំង", + "solid": "តាន់", + "hachure": "Hachure", + "zigzag": "Zigzag", + "crossHatch": "បន្ទាត់ឆ្នូតៗ", + "thin": "ស្តើង", + "bold": "ដឹត", + "left": "ខាងឆ្វេង", + "center": "កណ្ដាល", + "right": "នៅខាងស្ដាំ", + "extraBold": "ដិតបន្ថែម", + "architect": "ស្ថាបត្យករ", + "artist": "សិល្បៈករ", + "cartoonist": "អ្នកគំនូរជីវចល", + "fileTitle": "ឈ្មោះ​ឯកសារ", + "colorPicker": "ឧបករណ៍​ជ្រើស​ពណ៌", + "canvasColors": "ប្រើលើបាវ", + "canvasBackground": "ផ្ទៃខាងក្រោយបាវ", + "drawingCanvas": "តំបន់គំនូរ", + "layers": "ស្រទាប់", + "actions": "សកម្មភាព", + "language": "ភាសា", + "liveCollaboration": "សហការគ្នាផ្ទាល់...", + "duplicateSelection": "ចម្លង", + "untitled": "គ្មានឈ្មោះ", + "name": "ឈ្មោះ", + "yourName": "ឈ្មោះ​របស់​អ្នក", + "madeWithExcalidraw": "បង្កើតជាមួយ Excalidraw", + "group": "ការជ្រើសរើសជាក្រុម", + "ungroup": "បំបែកក្រុមការជ្រើសរើសជាក្រុម", + "collaborators": "អ្នកសហការ", + "showGrid": "បង្ហាញក្រឡាចត្រង្គ", + "addToLibrary": "បន្ថែមទៅបណ្ណាល័យ", + "removeFromLibrary": "លុបចេញពីបណ្ណាល័យ", + "libraryLoadingMessage": "កំពុងផ្ទុកបណ្ណាល័យ...", + "libraries": "រកមើលបណ្ណាល័យ", + "loadingScene": "កំពុង​ផ្ទុក​ស៊ីន...", + "align": "តម្រឹម", + "alignTop": "តម្រឹមផ្នែកខាងលើ", + "alignBottom": "តម្រឹមផ្នែកខាងក្រោម", + "alignLeft": "​តម្រឹម​ឆ្វេង", + "alignRight": "តម្រឹម​ស្តាំ", + "centerVertically": "កណ្តាល​បញ្ឈរ", + "centerHorizontally": "កណ្តាល​ផ្ដេក", + "distributeHorizontally": "ចែកចាយផ្ដេក", + "distributeVertically": "ចែកចាយបញ្ឈរ", + "flipHorizontal": "ត្រឡប់​ដោយ​ផ្ដេក", + "flipVertical": "ត្រឡប់​ដោយ​បញ្ឈរ", + "viewMode": "ម៉ូដ​បង្ហាញ", + "toggleExportColorScheme": "បិទ/បើក​ពណ៌​ចម្រុះ​នាំចេញ", + "share": "ចែករំលែក", + "showStroke": "បង្ហាញឧបករណ៍ជ្រើសរើសពណ៌ខ្វាច់", + "showBackground": "បង្ហាញឧបករណ៍ជ្រើសរើសពណ៌ផ្ទៃខាងក្រោយ", + "toggleTheme": "បិទ/បើកប្រធានបទ", + "personalLib": "បណ្ណាល័យផ្ទាល់ខ្លួន", + "excalidrawLib": "បណ្ណាល័យ Excalidraw", + "decreaseFontSize": "បន្ថយទំហំពុម្ពអក្សរ", + "increaseFontSize": "បង្កើនទំហំពុម្ពអក្សរ", + "unbindText": "ស្រាយអត្ថបទ", + "bindText": "ភ្ជាប់អត្ថបទទៅប្រអប់", + "createContainerFromText": "រុំអត្ថបទក្នុងប្រអប់មួយ", "link": { - "edit": "", - "create": "", - "label": "" + "edit": "កែតំណភ្ជាប់", + "create": "បង្កើតតំណភ្ជាប់", + "label": "តំណ" }, "lineEditor": { - "edit": "", - "exit": "" + "edit": "កែសម្រួលបន្ទាត់", + "exit": "ចាកចេញពីការកែសម្រួលបន្ទាត់" }, "elementLock": { - "lock": "", - "unlock": "", - "lockAll": "", - "unlockAll": "" + "lock": "ចាក់សោ", + "unlock": "ដោះសោ", + "lockAll": "ចាក់សោទាំងអស់", + "unlockAll": "ដោះសោទាំងអស់" }, - "statusPublished": "", - "sidebarLock": "" + "statusPublished": "ត្រូវបានបោះពុម្ពផ្សាយ", + "sidebarLock": "ទុករបារចំហៀងបើក" }, "library": { - "noItems": "", - "hint_emptyLibrary": "", - "hint_emptyPrivateLibrary": "" + "noItems": "មិនទាន់មានធាតុបន្ថែមទេ...", + "hint_emptyLibrary": "ជ្រើសរើសធាតុនៅលើបាវដើម្បីបន្ថែមវានៅទីនេះ ឬដំឡើងបណ្ណាល័យពីឃ្លាំងសាធារណៈខាងក្រោម។", + "hint_emptyPrivateLibrary": "ជ្រើសរើសធាតុនៅលើបាវដើម្បីបន្ថែមវានៅទីនេះ" }, "buttons": { - "clearReset": "", - "exportJSON": "", - "exportImage": "", - "export": "", - "exportToPng": "", - "exportToSvg": "", - "copyToClipboard": "", - "copyPngToClipboard": "", - "scale": "", - "save": "", - "saveAs": "", - "load": "", - "getShareableLink": "", - "close": "", - "selectLanguage": "", - "scrollBackToContent": "", - "zoomIn": "", - "zoomOut": "", - "resetZoom": "", - "menu": "", - "done": "", - "edit": "", - "undo": "", - "redo": "", - "resetLibrary": "", - "createNewRoom": "", - "fullScreen": "", - "darkMode": "", - "lightMode": "", - "zenMode": "", - "exitZenMode": "", - "cancel": "", - "clear": "", - "remove": "", - "publishLibrary": "", - "submit": "", - "confirm": "" + "clearReset": "កំណត់បាវឡើងវិញ", + "exportJSON": "នាំចេញជាឯកសារ", + "exportImage": "នាំរូបភាពចេញ", + "export": "រក្សាទុក​នៅ...", + "exportToPng": "នាំចេញជា PNG", + "exportToSvg": "នាំចេញជា SVG", + "copyToClipboard": "ចម្លងទៅក្តារតម្បៀតខ្ទាស់", + "copyPngToClipboard": "ចម្លង PNG ទៅក្តារតម្បៀតខ្ទាស់", + "scale": "មាត្រដ្ឋាន", + "save": "រក្សាទុកទៅឯកសារបច្ចុប្បន្ន", + "saveAs": "រក្សាទុក​ជា", + "load": "បើក", + "getShareableLink": "យកតំណដែលអាចចែករំលែកបាន", + "close": "បិទ", + "selectLanguage": "រើសភាសា", + "scrollBackToContent": "រំកិលត្រឡប់ទៅមាតិកាវិញ", + "zoomIn": "ពង្រីក", + "zoomOut": "បង្រួម", + "resetZoom": "កំណត់ការពង្រីកឡើងវិញ", + "menu": "ម៉ឺនុយ", + "done": "រួចរាល់", + "edit": "កែ", + "undo": "ត្រឡប់វិញ", + "redo": "ធ្វើ​វិញ", + "resetLibrary": "កំណត់បណ្ណាល័យឡើងវិញ", + "createNewRoom": "បង្កើតបន្ទប់ថ្មី", + "fullScreen": "ពេញ​អេក្រង់", + "darkMode": "ម៉ូដងងឹត", + "lightMode": "ម៉ូដភ្លឺ", + "zenMode": "ម៉ូត Zen", + "exitZenMode": "ចេញពី zen​ ម៉ូត", + "cancel": "បោះបង់", + "clear": "សម្អាត", + "remove": "ដកចេញ", + "publishLibrary": "បោះពុម្ពផ្សាយ", + "submit": "ដាក់​ស្នើ", + "confirm": "បញ្ជាក់" }, "alerts": { - "clearReset": "", - "couldNotCreateShareableLink": "", - "couldNotCreateShareableLinkTooBig": "", - "couldNotLoadInvalidFile": "", - "importBackendFailed": "", - "cannotExportEmptyCanvas": "", - "couldNotCopyToClipboard": "", - "decryptFailed": "", - "uploadedSecurly": "", - "loadSceneOverridePrompt": "", - "collabStopOverridePrompt": "", - "errorAddingToLibrary": "", - "errorRemovingFromLibrary": "", - "confirmAddLibrary": "", - "imageDoesNotContainScene": "", - "cannotRestoreFromImage": "", - "invalidSceneUrl": "", - "resetLibrary": "", - "removeItemsFromsLibrary": "", - "invalidEncryptionKey": "", - "collabOfflineWarning": "" + "clearReset": "វានឹងសម្អាតបាវទាំងមូល។ តើ​អ្នក​ប្រាកដ​ឬ​អត់?", + "couldNotCreateShareableLink": "មិនអាចបង្កើតតំណដែលអាចចែករំលែកបានទេ។", + "couldNotCreateShareableLinkTooBig": "មិន​អាច​បង្កើត​តំណ​ដែល​អាច​ចែក​រំលែក​បាន៖ ស៊ីន​ធំ​ពេក", + "couldNotLoadInvalidFile": "មិនអាចផ្ទុកឯកសារមិនត្រឹមត្រូវបានទេ។", + "importBackendFailed": "ការនាំចូលពីម៉ាស៊ីនមេបានបរាជ័យ។", + "cannotExportEmptyCanvas": "មិនអាចនាំចេញបាវទទេបានទេ។", + "couldNotCopyToClipboard": "មិនអាចចម្លងទៅក្ដារតម្បៀតខ្ទាស់បានទេ។", + "decryptFailed": "មិនអាចឌិគ្រីបទិន្នន័យបានទេ។", + "uploadedSecurly": "ការបង្ហោះត្រូវបានការពារដោយការអ៊ិនគ្រីបពីចុងដល់ចប់ មានន័យថា ទាំងម៉ាស៊ីនមេរបស់ Excalidraw ឬភាគីទីបីមិនអាចអានខ្លឹមសារបានទេ។", + "loadSceneOverridePrompt": "ការផ្ទុកគំនូរខាងក្រៅនឹងជំនួសមាតិកាដែលមានស្រាប់របស់អ្នក។ តើអ្នកចង់បន្តទេ?", + "collabStopOverridePrompt": "ការបញ្ឈប់សម័យនឹងសរសេរជាន់លើគំនូរដែលបានរក្សាទុកនៅលើកុំព្យូទ័ររបស់អ្នកពីមុន។ តើ​អ្នក​ប្រាកដ​ឬ​អត់?\n\n(ប្រសិនបើអ្នកចង់រក្សាគំនូរដែលនៅលើកុំព្យូទ័ររបស់អ្នក គ្រាន់តែបិទផ្ទាំងកម្មវិធីរុករក។)", + "errorAddingToLibrary": "មិនអាចបន្ថែមធាតុទៅបណ្ណាល័យបានទេ", + "errorRemovingFromLibrary": "មិនអាចលុបធាតុចេញពីបណ្ណាល័យបានទេ", + "confirmAddLibrary": "វានឹងបន្ថែមរូបរាង {{numShapes}} ទៅបណ្ណាល័យរបស់អ្នក។ តើ​អ្នក​ប្រាកដ​ឬ​អត់?", + "imageDoesNotContainScene": "រូបភាពនេះហាក់ដូចជាមិនមានទិន្នន័យស៊ីនណាមួយទេ។ តើអ្នកបានបើកការបង្កប់ស៊ីននៅពេលនាំចេញទេ?", + "cannotRestoreFromImage": "មិនអាចស្ដារស៊ីនពីឯកសាររូបភាពនេះបានទេ", + "invalidSceneUrl": "មិន​អាច​នាំចូល​ស៊ីន​ពី URL ដែល​បាន​ផ្តល់​ឱ្យ​ទេ។ វាមានទម្រង់ខុស ឬមិនមានទិន្នន័យ Excalidraw JSON ដែលត្រឹមត្រូវ។", + "resetLibrary": "វានឹងសម្អាតបាវទាំងមូល។ តើ​អ្នក​ប្រាកដ​ឬ​អត់?", + "removeItemsFromsLibrary": "តើអ្នកប្រាកដថាចង់លុប {{count}} ធាតុចេញពីបណ្ណាល័យទេ?", + "invalidEncryptionKey": "សោអ៊ីនគ្រីបត្រូវតែមាន 22 តួអក្សរ។ ការសហការផ្ទាល់ត្រូវបានបិទ។", + "collabOfflineWarning": "គ្មានការតភ្ជាប់អ៊ីនធឺណិត។\nការផ្លាស់ប្តូររបស់អ្នកនឹងមិនត្រូវបានរក្សាទុកទេ!" }, "errors": { - "unsupportedFileType": "", - "imageInsertError": "", - "fileTooBig": "", - "svgImageInsertError": "", - "invalidSVGString": "", - "cannotResolveCollabServer": "", - "importLibraryError": "", - "collabSaveFailed": "", - "collabSaveFailed_sizeExceeded": "", + "unsupportedFileType": "ប្រភេទឯកសារមិនត្រូវបានគាំទ្រទេ។", + "imageInsertError": "មិនអាចបញ្ចូលរូបភាពបានទេ។ សូម​ព្យាយាម​ម្តង​ទៀត​នៅ​ពេល​ក្រោយ……", + "fileTooBig": "ឯកសារធំពេក។ ទំហំអតិបរមាដែលអនុញ្ញាតគឺ {{maxSize}}។", + "svgImageInsertError": "មិនអាចបញ្ចូលរូបភាព SVG បានទេ។ ស្លាក SVG ហាក់ដូចជាមិនត្រឹមត្រូវ។", + "invalidSVGString": "SVG មិន​ត្រឹមត្រូវ។", + "cannotResolveCollabServer": "មិនអាចភ្ជាប់ទៅម៉ាស៊ីនមេសហការផ្ទាល់បានទេ។ សូមផ្ទុកទំព័រឡើងវិញ ហើយព្យាយាមម្តងទៀត។", + "importLibraryError": "មិនអាចផ្ទុកបណ្ណាល័យបានទេ។", + "collabSaveFailed": "មិនអាចរក្សាទុកទៅម៉ាស៊ីនមេបានទេ។ ប្រសិនបើបញ្ហានៅតែបន្តកើតមាន​ អ្នកគួរតែរក្សាទុកឯកសាររបស់អ្នកនៅលើកុំព្យូទ័ររបស់អ្នកសិន ដើម្បីធានាថាការងាររបស់អ្នកមិនបាត់បង់។", + "collabSaveFailed_sizeExceeded": "មិនអាចរក្សាទុកទៅម៉ាស៊ីនមេបានទេ, ផ្ទាំងបាវហាក់ដូចជាធំពេក។ អ្នកគួរតែរក្សាទុកឯកសាររបស់អ្នកនៅលើកុំព្យូទ័ររបស់អ្នកសិន ដើម្បីធានាថាការងាររបស់អ្នកមិនបាត់បង់។", "brave_measure_text_error": { - "line1": "", - "line2": "", - "line3": "", - "line4": "" + "line1": "អ្នកហាក់ដូចជាកំពុងប្រើប្រាស់កម្មវិធីរុករកតាមអ៊ីនធឺណិត Brave ជាមួយនឹងការកំណត់ ការពារស្នាមម្រាមដៃយ៉ាងធ្ងន់ធ្ងរ ត្រូវបានបើក។", + "line2": "វាអាចបណ្តាលឱ្យមានការបំបែក ធាតុអត្ថបទ នៅក្នុងគំនូររបស់អ្នក។", + "line3": "យើងណែនាំយ៉ាងមុតមាំឱ្យបិទការកំណត់នេះ។ អ្នកអាចអនុវត្តតាម ជំហានទាំងនេះ ដើម្បីបិទការកំណត់នេះ។", + "line4": "ប្រសិនបើការបិទការកំណត់នេះមិនបានជួសជុលការបង្ហាញធាតុអត្ថបទទេ សូមដាក់ issue នៅលើ GitHub ឬរាយការណ៍នៅលើ Discord របស់យើង" } }, "toolBar": { - "selection": "", - "image": "", - "rectangle": "", - "diamond": "", - "ellipse": "", - "arrow": "", - "line": "", - "freedraw": "", - "text": "", - "library": "", - "lock": "", - "penMode": "", - "link": "", - "eraser": "", - "hand": "" + "selection": "ការជ្រើសរើស", + "image": "បញ្ចូលរូបភាព", + "rectangle": "ចតុ​កោណ​កែង​", + "diamond": "ពេជ្រ", + "ellipse": "ពងក្រពើ", + "arrow": "ព្រួញ", + "line": "បន្ទាត់", + "freedraw": "គូរ", + "text": "អត្ថបទ", + "library": "បណ្ណាល័យ", + "lock": "រក្សារឧបករណ៍ដែលបានជ្រើសរើសបន្ទាប់ពីគូររួច", + "penMode": "របៀបប៊ិច - ជៀសវាងការប៉ះ", + "link": "បន្ថែម/ធ្វើបច្ចុប្បន្នភាពតំណភ្ជាប់សម្រាប់រូបរាងដែលបានជ្រើសរើស", + "eraser": "ជ័រលុប", + "hand": "ដៃ (panning tool)" }, "headings": { - "canvasActions": "", - "selectedShapeActions": "", - "shapes": "" + "canvasActions": "សកម្មភាពបាវ", + "selectedShapeActions": "សកម្មភាពរបស់រាងដែលបានជ្រើសរើស", + "shapes": "រាង" }, "hints": { - "canvasPanning": "", - "linearElement": "", - "freeDraw": "", - "text": "", - "text_selected": "", - "text_editing": "", - "linearElementMulti": "", - "lockAngle": "", - "resize": "", - "resizeImage": "", - "rotate": "", - "lineEditor_info": "", - "lineEditor_pointSelected": "", - "lineEditor_nothingSelected": "", - "placeImage": "", - "publishLibrary": "", - "bindTextToElement": "", - "deepBoxSelect": "", - "eraserRevert": "", - "firefox_clipboard_write": "" + "canvasPanning": "ដើម្បីផ្លាស់ទីបាវ សូមសង្កត់កង់កណ្ដុរឬគ្រាប់ចុចspacebarខណៈពេលកំពុងអូស ឬប្រើឧបករណ៍ដៃ។", + "linearElement": "ចុចដើម្បីបង្កើតចំណុចច្រើន អូសដើម្បីបង្កើតបន្ទាត់មួយ", + "freeDraw": "ចុច​ហើយ​អូស លែង​ពេល​រួចរាល់", + "text": "គន្លឹះ៖ អ្នកក៏អាចបន្ថែមអត្ថបទដោយចុចពីរដងនៅកន្លែងណាមួយដោយប្រើឧបករណ៍ជ្រើសរើស", + "text_selected": "ចុចពីរដង ឬចុច ENTER ដើម្បីកែសម្រួលអត្ថបទ", + "text_editing": "ចុច Escape ឬ CtrlOrCmd +ENTER ដើម្បីបញ្ចប់ការកែសម្រួល", + "linearElementMulti": "ចុចលើចំណុចចុងក្រោយ ឬចុច Esc/Enter ដើម្បីបញ្ចប់", + "lockAngle": "អ្នកអាចសង្កត់ Shift ដើម្បីកំណត់មុំ", + "resize": "អ្នកអាចសង្កត់ SHIFT ដើម្បីបងំ្ខឲមានសមាមាត្រ ខណៈពេលដែលប្តូរទំហំ\nសង្កត់ ALT ដើម្បីប្តូរទំហំពីកណ្តាល", + "resizeImage": "អ្នកអាចប្តូរទំហំរូបភាពដោយសេរីដោយសង្កត់ SHIFT,\nសង្កត់ ALT ដើម្បីប្តូរទំហំពីកណ្តាល", + "rotate": "អ្នកអាចសង្កត់ Shift ខណៈពេលកំពុងបង្វិល ដើម្បីកម្រិតមុំ", + "lineEditor_info": "សង្កត់ CtrlOrCmd ហើយចុចពីរដង ឬចុច CtrlOrCmd + Enter ដើម្បីកែសម្រួលចំណុច", + "lineEditor_pointSelected": "ចុច Delete ដើម្បីលុបចំណុច(ច្រើន)\nCtrlOrCmd+D ដើម្បីចម្លង, ឬអូសដើម្បីផ្លាស់ទី", + "lineEditor_nothingSelected": "ជ្រើសរើសចំណុចដែលត្រូវកែសម្រួល (សង្កត់ SHIFT ដើម្បីជ្រើសរើសច្រើនចំណុច)\nឬ សង្កត់ Alt ហើយចុចដើម្បីបន្ថែមចំណុចថ្មី។", + "placeImage": "ចុចដើម្បីដាក់រូបភាព ឬចុចហើយអូសដើម្បីកំណត់ទំហំរបស់រូបភាពដោយដៃ", + "publishLibrary": "បោះពុម្ពផ្សាយបណ្ណាល័យផ្ទាល់ខ្លួនរបស់អ្នក", + "bindTextToElement": "ចុច Enter ដើម្បីបន្ថែមអត្ថបទ", + "deepBoxSelect": "សង្កត់ CtrlOrCmd ដើម្បីជ្រើសរើសយ៉ាងជ្រៅ និងជៀសវាងការអូស", + "eraserRevert": "សង្កត់ Alt ដើម្បីដកការជ្រើសរើសធាតុដែលត្រូវបានសម្គាល់សម្រាប់ការលុប", + "firefox_clipboard_write": "បើកមុខងារនេះដោយកំណត់ទង់ \"dom.events.asyncClipboard.clipboardItem\" ទៅ \"true\" \nដើម្បីផ្លាស់ប្តូរទង់កម្មវិធីរុករកនៅក្នុង Firefox សូមចូលទៅកាន់ទំព័រ \"about:config\"។" }, "canvasError": { - "cannotShowPreview": "", - "canvasTooBig": "", - "canvasTooBigTip": "" + "cannotShowPreview": "មិនអាចបង្ហាញការមើលជាមុនបាន", + "canvasTooBig": "បាវអាចមានទំហំធំពេក។", + "canvasTooBigTip": "គន្លឹះ៖ ព្យាយាមផ្លាស់ទីធាតុដែលឆ្ងាយបំផុតឱ្យទៅជិតគ្នាបន្តិច។" }, "errorSplash": { - "headingMain": "", - "clearCanvasMessage": "", - "clearCanvasCaveat": "", - "trackedToSentry": "", - "openIssueMessage": "", - "sceneContent": "" + "headingMain": "បានជួបប្រទះកំហុសមួយ។ សូមព្យាយាម ។", + "clearCanvasMessage": "ប្រសិនបើការផ្ទុកឡើងវិញមិនអាចដោះស្រាយកំហុសបានទេ សូមសាកល្បង ", + "clearCanvasCaveat": " នេះនឹងបណ្តាលឱ្យបាត់បង់ការងារ ", + "trackedToSentry": "កំហុសជាមួយលេខសម្គាល់ {{eventId}} ត្រូវបានតាមដាននៅលើប្រព័ន្ធរបស់យើង។", + "openIssueMessage": "យើងមានការប្រុងប្រយ័ត្នខ្លាំងណាស់ក្នុងការមិនបញ្ចូលព័ត៌មានរបស់ស៊ីនរបស់អ្នកទៅលើកំហុស។ ប្រសិនបើស៊ីនរបស់អ្នកមិនមានលក្ខណៈឯកជនទេ សូមពិចារណាបន្តទៅកាន់ សូមបញ្ចូលព័ត៌មានខាងក្រោមដោយចម្លង និងបិទភ្ជាប់វាទៅក្នុងបញ្ហារបស់ GitHub។", + "sceneContent": "មាតិកាបាវ៖" }, "roomDialog": { - "desc_intro": "", - "desc_privacy": "", - "button_startSession": "", - "button_stopSession": "", - "desc_inProgressIntro": "", - "desc_shareLink": "", - "desc_exitSession": "", - "shareTitle": "" + "desc_intro": "អ្នកអាចអញ្ជើញអ្នកដទៃឱ្យសហការជាមួយអ្នកនៅលើស៊ីនបច្ចុប្បន្ន។", + "desc_privacy": "កុំបារម្ភ វគ្គប្រើការអ៊ិនគ្រីបពីចុងដល់ចប់ ដូច្នេះអ្វីដែលអ្នកគូរនឹងនៅតែជាឯកជន។ សូម្បីតែម៉ាស៊ីនមេរបស់យើងក៏នឹងមិនអាចមើលឃើញអ្វីដែលអ្នកកំពុងធ្វើដែរ។", + "button_startSession": "ចាប់ផ្តើមវគ្គ", + "button_stopSession": "បញ្ឈប់វគ្គ", + "desc_inProgressIntro": "វគ្គសហការផ្ទាល់ឥឡូវនេះកំពុងដំណើរការ។", + "desc_shareLink": "ចែករំលែកតំណនេះជាមួយអ្នកដែលអ្នកចង់សហការជាមួយ៖", + "desc_exitSession": "ការបញ្ឈប់វគ្គនេះនឹងផ្តាច់អ្នកចេញពីបន្ទប់ ប៉ុន្តែអ្នកនឹងនៅតែអាចបន្តប្រើបាវនៅលើកុំព្យូទ័ររបស់អ្នក។ សូមចំណាំថាវានឹងមិនប៉ះពាល់ដល់អ្នកប្រើប្រាស់ផ្សេងទៀតទេ​ ហើយពួកគេនឹងនៅតែអាចបន្តសហការលើកំណែរបស់ពួកគេ។", + "shareTitle": "ចូលរួមវគ្គសហការផ្ទាល់នៅលើ Excalidraw" }, "errorDialog": { - "title": "" + "title": "មានកំហុស" }, "exportDialog": { - "disk_title": "", - "disk_details": "", - "disk_button": "", - "link_title": "", - "link_details": "", - "link_button": "", - "excalidrawplus_description": "", - "excalidrawplus_button": "", - "excalidrawplus_exportError": "" + "disk_title": "រក្សាទុកទៅថាស", + "disk_details": "នាំចេញទិន្នន័យរបស់ស៊ីនជាឯកសារដែលអ្នកអាចនាំចូលនៅពេលក្រោយ។", + "disk_button": "រក្សាទុកក្នុងឯកសារ", + "link_title": "តំណដែលអាចចែករំលែកបាន", + "link_details": "នាំចេញជាតំណបានតែមើលឬអាន។", + "link_button": "នាំចេញជាតំណ", + "excalidrawplus_description": "រក្សាទុកស៊ីនទៅកន្លែងធ្វើការ Excalidraw+ របស់អ្នក។", + "excalidrawplus_button": "នាំចេញ", + "excalidrawplus_exportError": "មិនអាចនាំចេញទៅ Excalidraw+ បានទេនៅពេលនេះ..." }, "helpDialog": { - "blog": "", - "click": "", - "deepSelect": "", - "deepBoxSelect": "", - "curvedArrow": "", - "curvedLine": "", - "documentation": "", - "doubleClick": "", - "drag": "", - "editor": "", - "editLineArrowPoints": "", - "editText": "", - "github": "", - "howto": "", - "or": "", - "preventBinding": "", - "tools": "", - "shortcuts": "", - "textFinish": "", - "textNewLine": "", - "title": "", - "view": "", - "zoomToFit": "", - "zoomToSelection": "", - "toggleElementLock": "", - "movePageUpDown": "", - "movePageLeftRight": "" + "blog": "អានប្លក់របស់យើង", + "click": "ចុច", + "deepSelect": "ការជ្រើសរើសជាក្រុម", + "deepBoxSelect": "ជ្រើសរើសជាក្រុម និង ជៀសវាងការអូសទាញផ្លាស់ទី", + "curvedArrow": "ព្រួញកោង", + "curvedLine": "ព្រួញកោង", + "documentation": "ឯកសារ", + "doubleClick": "ចុច​ពីរ​ដង", + "drag": "អូស", + "editor": "កម្មវិធីនិពន្ធ", + "editLineArrowPoints": "កែសម្រួលចំណុចនៃបន្ទាត់ ឬព្រួញ", + "editText": "បន្ថែម ឬកែសម្រួលអត្ថបទ", + "github": "រកឃើញបញ្ហា? ដាក់ស្នើ", + "howto": "ឯកសារជំនួយ", + "or": "ឬ", + "preventBinding": "ទប់ស្កាត់ការចងព្រួញ", + "tools": "ឧបករណ៍", + "shortcuts": "ផ្លូវកាត់ក្តារចុច", + "textFinish": "បញ្ចប់ការកែសម្រួល (កម្មវិធីនិពន្ធអត្ថបទ)", + "textNewLine": "ចុះបន្ទាត់ (កម្មវិធីនិពន្ធអត្ថបទ)", + "title": "ជំនួយ", + "view": "បង្ហាញ", + "zoomToFit": "ធ្វើមាត្រដ្ឋានឱ្យសមនឹងធាតុទាំងអស់។", + "zoomToSelection": "ពង្រីកទៅការជ្រើសរើស", + "toggleElementLock": "ចាក់សោ/ដោះសោការជ្រើសរើស", + "movePageUpDown": "ផ្លាស់ទីទំព័រឡើងលើ/ចុះក្រោម", + "movePageLeftRight": "ផ្លាស់ទីទំព័រទៅឆ្វេង/ស្ដាំ" }, "clearCanvasDialog": { - "title": "" + "title": "សម្អាតបាវ" }, "publishDialog": { - "title": "", - "itemName": "", - "authorName": "", - "githubUsername": "", - "twitterUsername": "", - "libraryName": "", - "libraryDesc": "", - "website": "", + "title": "បោះពុម្ពបណ្ណាល័យ", + "itemName": "ឈ្មោះ​ធាតុ", + "authorName": "ឈ្មោះអ្នកនិពន្ធ", + "githubUsername": "ឈ្មោះអ្នកប្រើ GitHub", + "twitterUsername": "ឈ្មោះអ្នកប្រើ Twitter", + "libraryName": "ឈ្មោះបណ្ណាល័យ", + "libraryDesc": "ការពិពណ៌នាអំពីបណ្ណាល័យ", + "website": "គេហទំព័រ", "placeholder": { - "authorName": "", - "libraryName": "", - "libraryDesc": "", - "githubHandle": "", - "twitterHandle": "", - "website": "" + "authorName": "ឈ្មោះរបស់អ្នក ឬឈ្មោះអ្នកប្រើប្រាស់", + "libraryName": "ឈ្មោះបណ្ណាល័យរបស់អ្នក", + "libraryDesc": "ការពិពណ៌នាអំពីបណ្ណាល័យរបស់អ្នក នឹងអនុញ្ញាតឱ្យអ្នក​ផ្សេងយល់ពីការប្រើប្រាស់របស់វា។", + "githubHandle": "ឈ្មោះអ្នកប្រើ GitHub (ជាជម្រើស) ដូច្នេះអ្នកអាចកែសម្រួលបណ្ណាល័យបាននៅពេលដាក់ស្នើសម្រាប់ការពិនិត្យ", + "twitterHandle": "ឈ្មោះអ្នកប្រើប្រាស់ Twitter (ជាជម្រើស) ដូច្នេះយើងដឹងថាអ្នកណាដែលត្រូវផ្តល់ក្រេឌីតនៅពេលផ្សព្វផ្សាយតាម Twitter", + "website": "ភ្ជាប់ទៅគេហទំព័រផ្ទាល់ខ្លួនរបស់អ្នក ឬគេហទំព័រផ្សេងទៀត (ជាជម្រើស)" }, "errors": { - "required": "", - "website": "" + "required": "ត្រូវបានទាមទារ", + "website": "សូមវាយបញ្ចូល URL ​ដែលត្រឹមត្រូវ" }, - "noteDescription": "", - "noteGuidelines": "", - "noteLicense": "", - "noteItems": "", - "atleastOneLibItem": "", - "republishWarning": "" + "noteDescription": "ដាក់ស្នើបណ្ណាល័យរបស់អ្នកដើម្បីដាក់បញ្ចូលក្នុង ឃ្លាំងបណ្ណាល័យសាធារណៈ សម្រាប់ឲ្យអ្នកផ្សេងប្រើក្នុងគំនូររបស់ពួកគេ។", + "noteGuidelines": "បណ្ណាល័យត្រូវតែអនុម័តដោយដៃជាមុនសិន។ សូមអាន ការណែនាំ មុនពេលដាក់ស្នើ។ ការប្រាស្រ័យទាក់ទងជាបន្តបន្ទាប់ និងការផ្លាស់ប្តូរបណ្ណាល័យទាមទារឱ្យអ្នកមានគណនី GitHub ប៉ុន្តែមិនត្រូវបានទាមទារយ៉ាងតឹងរ៉ឹងទេ។", + "noteLicense": "តាមរយៈការដាក់ស្នើ អ្នកយល់ព្រមថាបណ្ណាល័យនឹងត្រូវបានបោះពុម្ពផ្សាយក្រោម អាជ្ញាប័ណ្ណ MIT ដែលមានន័យយ៉ាងខ្លី អ្នកណាក៏អាចប្រើប្រាស់វាបានដោយគ្មានការរឹតត្បិត។", + "noteItems": "ធាតុនីមួយៗនៅក្នុងបណ្ណាល័យត្រូវតែមានឈ្មោះផ្ទាល់ខ្លួនដើម្បីយើងអាចត្រងវាបាន។ ធាតុខាងក្រោមនឹងត្រូវបានរួមបញ្ចូល:", + "atleastOneLibItem": "សូមជ្រើសរើសយ៉ាងហោចណាស់ធាតុបណ្ណាល័យមួយដើម្បីចាប់ផ្តើម", + "republishWarning": "ចំណាំ៖ ធាតុដែលត្រូវបានជ្រើសរើសមួយចំនួនត្រូវបានសម្គាល់ថាបានបោះពុម្ព/បញ្ជូនរួចរាល់ហើយ។ អ្នកគួរតែបញ្ជូនធាតុឡើងវិញនៅពេលដែលធ្វើបច្ចុប្បន្នភាពបណ្ណាល័យដែលមានស្រាប់ ឬការបញ្ជូន។" }, "publishSuccessDialog": { - "title": "", - "content": "" + "title": "បណ្ណាល័យត្រូវបានដាក់ស្នើ", + "content": "សូមអរគុណ {{authorName}}។ បណ្ណាល័យរបស់អ្នកត្រូវបានបញ្ជូនមកពិនិត្យ។ សូមចុច ទីនេះ ដើម្បីតាមដានស្ថានភាពនៃការដាក់ស្នើនេះ។" }, "confirmDialog": { - "resetLibrary": "", - "removeItemsFromLib": "" + "resetLibrary": "កំណត់បណ្ណាល័យឡើងវិញ", + "removeItemsFromLib": "លុបធាតុដែលបានជ្រើសរើសចេញពីបណ្ណាល័យ" }, "encrypted": { - "tooltip": "", - "link": "" + "tooltip": "គំនូររបស់អ្នកត្រូវបានអ៊ិនគ្រីបពីចុងដល់ចប់ ដូច្នេះម៉ាស៊ីនមេរបស់ Excalidraw នឹងមិនឃើញពួកវាទេ។", + "link": "ប្លក់ផុសលើការអ៊ិនគ្រីបពីចុងដល់ចុងក្នុង Excalidraw" }, "stats": { - "angle": "", - "element": "", - "elements": "", - "height": "", - "scene": "", - "selected": "", - "storage": "", - "title": "", - "total": "", - "version": "", - "versionCopy": "", - "versionNotAvailable": "", - "width": "" + "angle": "មុំ", + "element": "ធាតុ", + "elements": "ធាតុច្រើន", + "height": "កម្ពស់", + "scene": "ស៊ីន", + "selected": "បានជ្រើសរើស", + "storage": "ការផ្ទុក", + "title": "ស្ថិតិសម្រាប់ nerds", + "total": "សរុប", + "version": "ជំនាន់:", + "versionCopy": "ចុចដើម្បីចម្លង", + "versionNotAvailable": "កំណែមិនអាចប្រើបាន", + "width": "ទទឹង​" }, "toast": { - "addedToLibrary": "", - "copyStyles": "", - "copyToClipboard": "", - "copyToClipboardAsPng": "", - "fileSaved": "", - "fileSavedToFilename": "", - "canvas": "", - "selection": "", - "pasteAsSingleElement": "" + "addedToLibrary": "បានបន្ថែមទៅបណ្ណាល័យ", + "copyStyles": "រចនាប័ទ្មត្រូវបានចម្លង។", + "copyToClipboard": "បានចម្លងទៅក្ដារតម្បៀតខ្ទាស់។", + "copyToClipboardAsPng": "បានចម្លង {{exportSelection}} ទៅក្ដារតម្បៀតខ្ទាស់ជា PNG\n({{exportColorScheme}})", + "fileSaved": "ឯកសារត្រូវបានរក្សាទុក។", + "fileSavedToFilename": "បានរក្សាទុកនៅក្នុង {filename}", + "canvas": "តំបន់គំនូរ", + "selection": "ការជ្រើសរើស", + "pasteAsSingleElement": "ប្រើ {{shortcut}} ដើម្បីបិទភ្ជាប់ជាធាតុតែមួយ,\nឬបិទភ្ជាប់ទៅក្នុងកម្មវិធីនិពន្ធអត្ថបទដែលមានស្រាប់" }, "colors": { "transparent": "ថ្លាមើលធ្លុះ", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "ពណ៍ខ្មៅ", + "white": "ពណ៌ស", + "red": "ពណ៌ក្រហម", + "pink": "ពណ៌ផ្កាឈូក", + "grape": "ពណ៌ទំពាំងបាយជូរ", + "violet": "ពណ៌ស្វាយ", + "gray": "ពណ៌ប្រផេះ", + "blue": "ពណ៌ខៀវ", + "cyan": "ពណ៌ផ្ទៃមេឃ", + "teal": "ពណ៌​ខៀវបៃតង", + "green": "ពណ៌បៃតង", + "yellow": "ពណ៌លឿង", + "orange": "ពណ៌ទឹកក្រូច", + "bronze": "ពណ៌សំរិទ្ធ" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "ពណ៌ផ្ទាល់ខ្លួនដែលប្រើច្រើនបំផុត", + "colors": "ពណ៌", + "shades": "ស្រមោល", + "hexCode": "លេខកូដ hex", + "noShades": "មិនមានស្រមោលសម្រាប់ពណ៌នេះទេ" } } diff --git a/src/locales/ko-KR.json b/src/locales/ko-KR.json index 3ec5233d7b..2bb2bc7fa5 100644 --- a/src/locales/ko-KR.json +++ b/src/locales/ko-KR.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "투명", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "검정색", + "white": "흰색", + "red": "빨강색", + "pink": "핑크색", + "grape": "그레이프", + "violet": "바이올렛", + "gray": "회색", + "blue": "파란색", + "cyan": "시안", + "teal": "틸", + "green": "녹색", + "yellow": "노란색", + "orange": "주황색", + "bronze": "브론즈" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "자주 사용된 커스텀 색상들", + "colors": "색상", + "shades": "색조", + "hexCode": "Hex 코드", + "noShades": "이 색상에는 색조가 없습니다" } } diff --git a/src/locales/ku-TR.json b/src/locales/ku-TR.json index 90f4ceec67..4ef15d8530 100644 --- a/src/locales/ku-TR.json +++ b/src/locales/ku-TR.json @@ -75,10 +75,10 @@ "language": "زمان", "liveCollaboration": "هاوکاریکردنی زیندو...", "duplicateSelection": "لەبەرگرتنەوە", - "untitled": "Untitled", + "untitled": "بێ-ناو", "name": "ناو", "yourName": "ناوەکەت", - "madeWithExcalidraw": "Made with Excalidraw", + "madeWithExcalidraw": "دروستکراوە بە Excalidraw", "group": "دیاریکردنی گروپ", "ungroup": "گروپی دیاریکراوەکان لابەرە", "collaborators": "هاوکارەکان", diff --git a/src/locales/nb-NO.json b/src/locales/nb-NO.json index c624ac8e5f..5211a703b3 100644 --- a/src/locales/nb-NO.json +++ b/src/locales/nb-NO.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Gjennomsiktig", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Svart", + "white": "Hvit", + "red": "Rød", + "pink": "Rosa", + "grape": "Drue", + "violet": "Fiolett", + "gray": "Grå", + "blue": "Blå", + "cyan": "Turkis", + "teal": "Blågrønn", + "green": "Grønn", + "yellow": "Gul", + "orange": "Oransje", + "bronze": "Bronse" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Mest brukte egendefinerte farger", + "colors": "Farger", + "shades": "Toner", + "hexCode": "Heksadesimal kode", + "noShades": "Ingen toner tilgjengelig for denne fargen" } } diff --git a/src/locales/percentages.json b/src/locales/percentages.json index 1619ae9714..09e8386ad0 100644 --- a/src/locales/percentages.json +++ b/src/locales/percentages.json @@ -5,8 +5,8 @@ "ca-ES": 92, "cs-CZ": 67, "da-DK": 35, - "de-DE": 94, - "el-GR": 92, + "de-DE": 100, + "el-GR": 97, "en": 100, "es-ES": 93, "eu-ES": 94, @@ -17,39 +17,39 @@ "he-IL": 91, "hi-IN": 74, "hu-HU": 80, - "id-ID": 92, - "it-IT": 94, + "id-ID": 100, + "it-IT": 100, "ja-JP": 92, "kaa": 18, "kab-KAB": 92, "kk-KZ": 22, - "km-KH": 2, - "ko-KR": 94, + "km-KH": 100, + "ko-KR": 100, "ku-TR": 94, "lt-LT": 59, "lv-LV": 93, "mr-IN": 93, "my-MM": 43, - "nb-NO": 94, + "nb-NO": 100, "nl-NL": 87, "nn-NO": 81, "oc-FR": 91, "pa-IN": 82, "pl-PL": 83, - "pt-BR": 94, - "pt-PT": 93, - "ro-RO": 94, - "ru-RU": 94, + "pt-BR": 100, + "pt-PT": 97, + "ro-RO": 100, + "ru-RU": 100, "si-LK": 9, - "sk-SK": 94, - "sl-SI": 94, - "sv-SE": 94, + "sk-SK": 100, + "sl-SI": 100, + "sv-SE": 100, "ta-IN": 86, "th-TH": 41, "tr-TR": 91, "uk-UA": 94, "vi-VN": 59, - "zh-CN": 94, + "zh-CN": 100, "zh-HK": 27, - "zh-TW": 94 + "zh-TW": 100 } diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index 30ae031d55..614c782fd6 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Transparente", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Preto", + "white": "Branco", + "red": "Vermelho", + "pink": "Rosa", + "grape": "Uva", + "violet": "Violeta", + "gray": "Cinza", + "blue": "Azul", + "cyan": "Ciano", + "teal": "Verde-azulado", + "green": "Verde", + "yellow": "Amarelo", + "orange": "Laranja", + "bronze": "Bronze" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Cores personalizadas mais usadas", + "colors": "Cores", + "shades": "Tons", + "hexCode": "Código hexadecimal", + "noShades": "Sem tons disponíveis para essa cor" } } diff --git a/src/locales/pt-PT.json b/src/locales/pt-PT.json index 943e5db3f7..297daed100 100644 --- a/src/locales/pt-PT.json +++ b/src/locales/pt-PT.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Transparente", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", + "black": "Preto", + "white": "Branco", + "red": "Vermelho", + "pink": "Rosa", + "grape": "Uva", + "violet": "Violeta", + "gray": "Cinza", + "blue": "Azul", "cyan": "", "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "green": "Verde", + "yellow": "Amarelo", + "orange": "Laranja", + "bronze": "Bronze" }, "welcomeScreen": { "app": { @@ -425,8 +425,8 @@ }, "colorPicker": { "mostUsedCustomColors": "", - "colors": "", - "shades": "", + "colors": "Cores", + "shades": "Tons", "hexCode": "", "noShades": "" } diff --git a/src/locales/ro-RO.json b/src/locales/ro-RO.json index 5440f8f722..d6d6c3c3e4 100644 --- a/src/locales/ro-RO.json +++ b/src/locales/ro-RO.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Transparent", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Negru", + "white": "Alb", + "red": "Roșu", + "pink": "Roz", + "grape": "Struguriu", + "violet": "Violet", + "gray": "Gri", + "blue": "Albastru", + "cyan": "Cyan", + "teal": "Cyan-verde", + "green": "Verde", + "yellow": "Galben", + "orange": "Portocaliu", + "bronze": "Bronz" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Cele mai utilizate culori personalizate", + "colors": "Culori", + "shades": "Nuanțe", + "hexCode": "Cod hexa", + "noShades": "Nu este disponibilă nicio nuanță pentru această culoare" } } diff --git a/src/locales/ru-RU.json b/src/locales/ru-RU.json index 7ba5b3374e..2d2958d3ca 100644 --- a/src/locales/ru-RU.json +++ b/src/locales/ru-RU.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Прозрачный", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Чёрный", + "white": "Белый", + "red": "Красный", + "pink": "Розовый", + "grape": "Виноградный", + "violet": "Фиолетовый", + "gray": "Серый", + "blue": "Синий", + "cyan": "Голубой", + "teal": "Бирюзовый", + "green": "Зелёный", + "yellow": "Жёлтый", + "orange": "Оранжевый", + "bronze": "Бронзовый" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Часто используемые пользовательские цвета", + "colors": "Цвета", + "shades": "Оттенки", + "hexCode": "Шестнадцатеричный код", + "noShades": "Нет доступных оттенков для этого цвета" } } diff --git a/src/locales/sk-SK.json b/src/locales/sk-SK.json index 2123d7c1ad..c976091cc7 100644 --- a/src/locales/sk-SK.json +++ b/src/locales/sk-SK.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Priehľadná", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Čierna", + "white": "Biela", + "red": "Červená", + "pink": "Ružová", + "grape": "Hroznová fialová", + "violet": "Fialová", + "gray": "Sivá", + "blue": "Modrá", + "cyan": "Azúrová", + "teal": "Modrozelená", + "green": "Zelená", + "yellow": "Žltá", + "orange": "Oranžová", + "bronze": "Bronzová" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Najpoužívanejšie vlastné farby", + "colors": "Farby", + "shades": "Odtiene", + "hexCode": "Hex kód", + "noShades": "Pre túto farbu nie sú dostupné žiadne odtiene" } } diff --git a/src/locales/sl-SI.json b/src/locales/sl-SI.json index 902b2476fe..801a3bc03e 100644 --- a/src/locales/sl-SI.json +++ b/src/locales/sl-SI.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Prosojno", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Črna", + "white": "Bela", + "red": "Rdeča", + "pink": "Roza", + "grape": "Grozdje", + "violet": "Vijolična", + "gray": "Siva", + "blue": "Modra", + "cyan": "Cijan", + "teal": "Turkizna", + "green": "Zelena", + "yellow": "Rumena", + "orange": "Oranžna", + "bronze": "Bronasta" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Najpogosteje uporabljene barve po meri", + "colors": "Barve", + "shades": "Odtenki", + "hexCode": "Hex koda", + "noShades": "Odtenki za to barvo niso na voljo" } } diff --git a/src/locales/sv-SE.json b/src/locales/sv-SE.json index 641e3ce667..de2e9982c6 100644 --- a/src/locales/sv-SE.json +++ b/src/locales/sv-SE.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "Genomskinlig", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "Svart", + "white": "Vit", + "red": "Röd", + "pink": "Rosa", + "grape": "Lila", + "violet": "Violett", + "gray": "Grå", + "blue": "Blå", + "cyan": "Turkos", + "teal": "Blågrön", + "green": "Grön", + "yellow": "Gul", + "orange": "Orange", + "bronze": "Brons" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "Mest frekvent använda anpassade färger", + "colors": "Färger", + "shades": "Nyanser", + "hexCode": "Hex-kod", + "noShades": "Inga nyanser tillgängliga för denna färg" } } diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 93ad2c9480..50ba890732 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "透明", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "黑", + "white": "白", + "red": "红", + "pink": "粉红", + "grape": "紫红", + "violet": "蓝紫", + "gray": "灰", + "blue": "蓝", + "cyan": "青", + "teal": "蓝绿", + "green": "绿", + "yellow": "黄", + "orange": "橙", + "bronze": "古铜" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "常用自定义颜色", + "colors": "颜色", + "shades": "色调明暗", + "hexCode": "十六进制值", + "noShades": "此颜色没有可用的明暗变化" } } diff --git a/src/locales/zh-TW.json b/src/locales/zh-TW.json index 3be58ef564..b86edb2bc5 100644 --- a/src/locales/zh-TW.json +++ b/src/locales/zh-TW.json @@ -395,20 +395,20 @@ }, "colors": { "transparent": "透明", - "black": "", - "white": "", - "red": "", - "pink": "", - "grape": "", - "violet": "", - "gray": "", - "blue": "", - "cyan": "", - "teal": "", - "green": "", - "yellow": "", - "orange": "", - "bronze": "" + "black": "黑", + "white": "白", + "red": "紅", + "pink": "粉紅", + "grape": "深紫", + "violet": "藍紫", + "gray": "灰", + "blue": "藍", + "cyan": "青", + "teal": "藍綠", + "green": "綠", + "yellow": "黃", + "orange": "橘", + "bronze": "銅" }, "welcomeScreen": { "app": { @@ -424,10 +424,10 @@ } }, "colorPicker": { - "mostUsedCustomColors": "", - "colors": "", - "shades": "", - "hexCode": "", - "noShades": "" + "mostUsedCustomColors": "最常使用的自訂顏色", + "colors": "顏色", + "shades": "漸變色", + "hexCode": "Hex 碼", + "noShades": "沒有此顏色的漸變色" } } From 13780f390a668163504d245f88794206d458f199 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 24 May 2023 17:24:54 +0200 Subject: [PATCH 04/16] fix: add react v17 `useTransition` polyfill (#6618) --- src/components/LibraryMenuSection.tsx | 3 ++- src/hooks/useTransition.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useTransition.ts diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx index d1af854813..b6866e1e92 100644 --- a/src/components/LibraryMenuSection.tsx +++ b/src/components/LibraryMenuSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState, useTransition } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { LibraryUnit } from "./LibraryUnit"; import { LibraryItem } from "../types"; import Stack from "./Stack"; @@ -6,6 +6,7 @@ import clsx from "clsx"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { useAtom } from "jotai"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; +import { useTransition } from "../hooks/useTransition"; const ITEMS_PER_ROW = 4; const ROWS_RENDERED_PER_BATCH = 6; diff --git a/src/hooks/useTransition.ts b/src/hooks/useTransition.ts new file mode 100644 index 0000000000..bb107edbc0 --- /dev/null +++ b/src/hooks/useTransition.ts @@ -0,0 +1,9 @@ +import React, { useCallback } from "react"; + +/** noop polyfill for v17. Subset of API available */ +function useTransitionPolyfill() { + const startTransition = useCallback((callback: () => void) => callback(), []); + return [false, startTransition] as const; +} + +export const useTransition = React.useTransition || useTransitionPolyfill; From 75bea48b5487339abb3bd017731d5a291bd3fba1 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Wed, 24 May 2023 22:52:21 +0200 Subject: [PATCH 05/16] fix: export dialog shortcut toggles console on firefox (#6620) --- src/components/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/App.tsx b/src/components/App.tsx index e6f9616985..49919c957c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2156,6 +2156,7 @@ class App extends React.Component { event.shiftKey && event[KEYS.CTRL_OR_CMD] ) { + event.preventDefault(); this.setState({ openDialog: "imageExport" }); return; } From 6459ccda6aca3246832dbe9a4434839952b703b7 Mon Sep 17 00:00:00 2001 From: Alex Kim <45559664+alex-kim-dev@users.noreply.github.com> Date: Thu, 25 May 2023 19:27:41 +0500 Subject: [PATCH 06/16] feat: add flipping for multiple elements (#5578) * feat: add flipping when resizing multiple elements * fix: image elements not flipping its content * test: fix accidental resizing in grouping test * fix: angles not flipping vertically when resizing * feat: add flipping multiple elements with a command * revert: image elements not flipping its content This reverts commit cb989a6c66e62a02a8c04ce41f12507806c8d0a0. * fix: add special cases for flipping text & images * fix: a few corner cases for flipping * fix: remove angle flip * fix: bound text scaling when resizing * fix: linear elements drifting away after multiple flips * revert: fix linear elements drifting away after multiple flips This reverts commit bffc33dd3ffe56c72029eee6aca843d992bac7ab. * fix: linear elements unstable bounds * revert: linear elements unstable bounds This reverts commit 22ae9b02c4a49f0ed6448c27abe1969cf6abb1e3. * fix: hand-drawn lines shift after flipping * test: fix flipping tests * test: fix the number of context menu items * fix: incorrect scaling due to ignoring bound text when finding selection bounds * fix: bound text coordinates not being updated * fix: lines bound text rotation * fix: incorrect placement of bound lines on flip * remove redundant predicates in actionFlip * update test * refactor resizeElement with some renaming and comments * fix grouped bounded text elements not being flipped correctly * combine mutation for bounded text element * remove incorrect return * fix: linear elements bindings after flipping * revert: remove incorrect return This reverts commit e6b205ca900b504fe982e4ac1b3b19dcfca246b8. * fix: minimum size for all elements in selection --------- Co-authored-by: Ryan Di --- src/actions/actionFlip.ts | 204 ++--------------- src/element/resizeElements.ts | 210 +++++++++++++----- .../__snapshots__/contextmenu.test.tsx.snap | 10 - .../regressionTests.test.tsx.snap | 17 +- src/tests/contextmenu.test.tsx | 4 + src/tests/flip.test.tsx | 75 ++++--- src/tests/regressionTests.test.tsx | 2 +- 7 files changed, 240 insertions(+), 282 deletions(-) diff --git a/src/actions/actionFlip.ts b/src/actions/actionFlip.ts index 0dadc23b10..8edbbc4aa5 100644 --- a/src/actions/actionFlip.ts +++ b/src/actions/actionFlip.ts @@ -1,42 +1,17 @@ import { register } from "./register"; import { getSelectedElements } from "../scene"; import { getNonDeletedElements } from "../element"; -import { mutateElement } from "../element/mutateElement"; import { ExcalidrawElement, NonDeleted } from "../element/types"; -import { normalizeAngle, resizeSingleElement } from "../element/resizeElements"; -import { AppState } from "../types"; -import { getTransformHandles } from "../element/transformHandles"; -import { updateBoundElements } from "../element/binding"; +import { resizeMultipleElements } from "../element/resizeElements"; +import { AppState, PointerDownState } from "../types"; import { arrayToMap } from "../utils"; -import { - getElementAbsoluteCoords, - getElementPointsCoords, -} from "../element/bounds"; -import { isLinearElement } from "../element/typeChecks"; -import { LinearElementEditor } from "../element/linearElementEditor"; import { CODES, KEYS } from "../keys"; - -const enableActionFlipHorizontal = ( - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const eligibleElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); - return eligibleElements.length === 1 && eligibleElements[0].type !== "text"; -}; - -const enableActionFlipVertical = ( - elements: readonly ExcalidrawElement[], - appState: AppState, -) => { - const eligibleElements = getSelectedElements( - getNonDeletedElements(elements), - appState, - ); - return eligibleElements.length === 1; -}; +import { getCommonBoundingBox } from "../element/bounds"; +import { + bindOrUnbindSelectedElements, + isBindingEnabled, + unbindLinearElements, +} from "../element/binding"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -50,8 +25,6 @@ export const actionFlipHorizontal = register({ }, keyTest: (event) => event.shiftKey && event.code === CODES.H, contextItemLabel: "labels.flipHorizontal", - predicate: (elements, appState) => - enableActionFlipHorizontal(elements, appState), }); export const actionFlipVertical = register({ @@ -67,8 +40,6 @@ export const actionFlipVertical = register({ keyTest: (event) => event.shiftKey && event.code === CODES.V && !event[KEYS.CTRL_OR_CMD], contextItemLabel: "labels.flipVertical", - predicate: (elements, appState) => - enableActionFlipVertical(elements, appState), }); const flipSelectedElements = ( @@ -81,11 +52,6 @@ const flipSelectedElements = ( appState, ); - // remove once we allow for groups of elements to be flipped - if (selectedElements.length > 1) { - return elements; - } - const updatedElements = flipElements( selectedElements, appState, @@ -104,144 +70,20 @@ const flipElements = ( appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { - elements.forEach((element) => { - flipElement(element, appState); - // If vertical flip, rotate an extra 180 - if (flipDirection === "vertical") { - rotateElement(element, Math.PI); - } - }); + const { minX, minY, maxX, maxY } = getCommonBoundingBox(elements); + + resizeMultipleElements( + { originalElements: arrayToMap(elements) } as PointerDownState, + elements, + "nw", + true, + flipDirection === "horizontal" ? maxX : minX, + flipDirection === "horizontal" ? minY : maxY, + ); + + (isBindingEnabled(appState) + ? bindOrUnbindSelectedElements + : unbindLinearElements)(elements); + return elements; }; - -const flipElement = ( - element: NonDeleted, - appState: AppState, -) => { - const originalX = element.x; - const originalY = element.y; - const width = element.width; - const height = element.height; - const originalAngle = normalizeAngle(element.angle); - - // Rotate back to zero, if necessary - mutateElement(element, { - angle: normalizeAngle(0), - }); - // Flip unrotated by pulling TransformHandle to opposite side - const transformHandles = getTransformHandles(element, appState.zoom); - let usingNWHandle = true; - let nHandle = transformHandles.nw; - if (!nHandle) { - // Use ne handle instead - usingNWHandle = false; - nHandle = transformHandles.ne; - if (!nHandle) { - mutateElement(element, { - angle: originalAngle, - }); - return; - } - } - - let finalOffsetX = 0; - if (isLinearElement(element) && element.points.length < 3) { - finalOffsetX = - element.points.reduce((max, point) => Math.max(max, point[0]), 0) * 2 - - element.width; - } - - let initialPointsCoords; - if (isLinearElement(element)) { - initialPointsCoords = getElementPointsCoords(element, element.points); - } - const initialElementAbsoluteCoords = getElementAbsoluteCoords(element); - - if (isLinearElement(element) && element.points.length < 3) { - for (let index = 1; index < element.points.length; index++) { - LinearElementEditor.movePoints(element, [ - { - index, - point: [-element.points[index][0], element.points[index][1]], - }, - ]); - } - LinearElementEditor.normalizePoints(element); - } else { - const elWidth = initialPointsCoords - ? initialPointsCoords[2] - initialPointsCoords[0] - : initialElementAbsoluteCoords[2] - initialElementAbsoluteCoords[0]; - - const startPoint = initialPointsCoords - ? [initialPointsCoords[0], initialPointsCoords[1]] - : [initialElementAbsoluteCoords[0], initialElementAbsoluteCoords[1]]; - - resizeSingleElement( - new Map().set(element.id, element), - false, - element, - usingNWHandle ? "nw" : "ne", - true, - usingNWHandle ? startPoint[0] + elWidth : startPoint[0] - elWidth, - startPoint[1], - ); - } - - // Rotate by (360 degrees - original angle) - let angle = normalizeAngle(2 * Math.PI - originalAngle); - if (angle < 0) { - // check, probably unnecessary - angle = normalizeAngle(angle + 2 * Math.PI); - } - mutateElement(element, { - angle, - }); - - // Move back to original spot to appear "flipped in place" - mutateElement(element, { - x: originalX + finalOffsetX, - y: originalY, - width, - height, - }); - - updateBoundElements(element); - - if (initialPointsCoords && isLinearElement(element)) { - // Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin. - // There's still room for improvement since when the line roughness is > 1 - // we still have a small offset of the origin when fliipping the element. - const finalPointsCoords = getElementPointsCoords(element, element.points); - - const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0]; - const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2]; - - const coordsDiff = topLeftCoordsDiff + topRightCoordDiff; - - mutateElement(element, { - x: element.x + coordsDiff * 0.5, - y: element.y, - width, - height, - }); - } -}; - -const rotateElement = (element: ExcalidrawElement, rotationAngle: number) => { - const originalX = element.x; - const originalY = element.y; - let angle = normalizeAngle(element.angle + rotationAngle); - if (angle < 0) { - // check, probably unnecessary - angle = normalizeAngle(2 * Math.PI + angle); - } - mutateElement(element, { - angle, - }); - - // Move back to original spot - mutateElement(element, { - x: originalX, - y: originalY, - }); -}; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 67a6346be4..3610d7577c 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -14,17 +14,21 @@ import { NonDeleted, ExcalidrawElement, ExcalidrawTextElementWithContainer, + ExcalidrawImageElement, } from "./types"; +import type { Mutable } from "../utility-types"; import { getElementAbsoluteCoords, getCommonBounds, getResizedElementAbsoluteCoords, getCommonBoundingBox, + getElementPointsCoords, } from "./bounds"; import { isArrowElement, isBoundToContainer, isFreeDrawElement, + isImageElement, isLinearElement, isTextElement, } from "./typeChecks"; @@ -49,8 +53,12 @@ import { measureText, getBoundTextMaxHeight, } from "./textElement"; +import { LinearElementEditor } from "./linearElementEditor"; export const normalizeAngle = (angle: number): number => { + if (angle < 0) { + return angle + 2 * Math.PI; + } if (angle >= 2 * Math.PI) { return angle - 2 * Math.PI; } @@ -596,7 +604,7 @@ export const resizeSingleElement = ( } }; -const resizeMultipleElements = ( +export const resizeMultipleElements = ( pointerDownState: PointerDownState, selectedElements: readonly NonDeletedExcalidrawElement[], transformHandleType: "nw" | "ne" | "sw" | "se", @@ -627,8 +635,28 @@ const resizeMultipleElements = ( [], ); + // getCommonBoundingBox() uses getBoundTextElement() which returns null for + // original elements from pointerDownState, so we have to find and add these + // bound text elements manually. Additionally, the coordinates of bound text + // elements aren't always up to date. + const boundTextElements = targetElements.reduce((acc, { orig }) => { + if (!isLinearElement(orig)) { + return acc; + } + const textId = getBoundTextElementId(orig); + if (!textId) { + return acc; + } + const text = pointerDownState.originalElements.get(textId) ?? null; + if (!isBoundToContainer(text)) { + return acc; + } + const xy = LinearElementEditor.getBoundTextElementPosition(orig, text); + return [...acc, { ...text, ...xy }]; + }, [] as ExcalidrawTextElementWithContainer[]); + const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox( - targetElements.map(({ orig }) => orig), + targetElements.map(({ orig }) => orig).concat(boundTextElements), ); const direction = transformHandleType; @@ -640,12 +668,22 @@ const resizeMultipleElements = ( }; // anchor point must be on the opposite side of the dragged selection handle - // or be the center of the selection if alt is pressed + // or be the center of the selection if shouldResizeFromCenter const [anchorX, anchorY]: Point = shouldResizeFromCenter ? [midX, midY] : mapDirectionsToAnchors[direction]; - const mapDirectionsToPointerSides: Record< + const scale = + Math.max( + Math.abs(pointerX - anchorX) / (maxX - minX) || 0, + Math.abs(pointerY - anchorY) / (maxY - minY) || 0, + ) * (shouldResizeFromCenter ? 2 : 1); + + if (scale === 0) { + return; + } + + const mapDirectionsToPointerPositions: Record< typeof direction, [x: boolean, y: boolean] > = { @@ -655,68 +693,117 @@ const resizeMultipleElements = ( nw: [pointerX <= anchorX, pointerY <= anchorY], }; - // pointer side relative to anchor - const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[ + /** + * to flip an element: + * 1. determine over which axis is the element being flipped + * (could be x, y, or both) indicated by `flipFactorX` & `flipFactorY` + * 2. shift element's position by the amount of width or height (or both) or + * mirror points in the case of linear & freedraw elemenets + * 3. adjust element angle + */ + const [flipFactorX, flipFactorY] = mapDirectionsToPointerPositions[ direction ].map((condition) => (condition ? 1 : -1)); + const isFlippedByX = flipFactorX < 0; + const isFlippedByY = flipFactorY < 0; - // stop resizing if a pointer is on the other side of selection - if (pointerSideX < 0 && pointerSideY < 0) { - return; - } + const elementsAndUpdates: { + element: NonDeletedExcalidrawElement; + update: Mutable< + Pick + > & { + points?: ExcalidrawLinearElement["points"]; + fontSize?: ExcalidrawTextElement["fontSize"]; + baseline?: ExcalidrawTextElement["baseline"]; + scale?: ExcalidrawImageElement["scale"]; + }; + boundText: { + element: ExcalidrawTextElementWithContainer; + fontSize: ExcalidrawTextElement["fontSize"]; + baseline: ExcalidrawTextElement["baseline"]; + } | null; + }[] = []; - const scale = - Math.max( - (pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX), - (pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY), - ) * (shouldResizeFromCenter ? 2 : 1); + for (const { orig, latest } of targetElements) { + // bounded text elements are updated along with their container elements + if (isTextElement(orig) && isBoundToContainer(orig)) { + continue; + } - if (scale === 0) { - return; - } + const width = orig.width * scale; + const height = orig.height * scale; + const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY); - targetElements.forEach((element) => { - const width = element.orig.width * scale; - const height = element.orig.height * scale; - const x = anchorX + (element.orig.x - anchorX) * scale; - const y = anchorY + (element.orig.y - anchorY) * scale; + const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig); + const offsetX = orig.x - anchorX; + const offsetY = orig.y - anchorY; + const shiftX = isFlippedByX && !isLinearOrFreeDraw ? width : 0; + const shiftY = isFlippedByY && !isLinearOrFreeDraw ? height : 0; + const x = anchorX + flipFactorX * (offsetX * scale + shiftX); + const y = anchorY + flipFactorY * (offsetY * scale + shiftY); - // readjust points for linear & free draw elements const rescaledPoints = rescalePointsInElement( - element.orig, - width, - height, + orig, + width * flipFactorX, + height * flipFactorY, false, ); - const update: { - width: number; - height: number; - x: number; - y: number; - points?: Point[]; - fontSize?: number; - baseline?: number; - } = { - width, - height, + const update: typeof elementsAndUpdates[0]["update"] = { x, y, + width, + height, + angle, ...rescaledPoints, }; - let boundTextUpdates: { fontSize: number; baseline: number } | null = null; + if (isImageElement(orig) && targetElements.length === 1) { + update.scale = [orig.scale[0] * flipFactorX, orig.scale[1] * flipFactorY]; + } - const boundTextElement = getBoundTextElement(element.latest); + if (isLinearElement(orig) && (isFlippedByX || isFlippedByY)) { + const origBounds = getElementPointsCoords(orig, orig.points); + const newBounds = getElementPointsCoords( + { ...orig, x, y }, + rescaledPoints.points!, + ); + const origXY = [orig.x, orig.y]; + const newXY = [x, y]; - if (boundTextElement || isTextElement(element.orig)) { + const linearShift = (axis: "x" | "y") => { + const i = axis === "x" ? 0 : 1; + return ( + (newBounds[i + 2] - + newXY[i] - + (origXY[i] - origBounds[i]) * scale + + (origBounds[i + 2] - origXY[i]) * scale - + (newXY[i] - newBounds[i])) / + 2 + ); + }; + + if (isFlippedByX) { + update.x -= linearShift("x"); + } + + if (isFlippedByY) { + update.y -= linearShift("y"); + } + } + + let boundText: typeof elementsAndUpdates[0]["boundText"] = null; + + const boundTextElement = getBoundTextElement(latest); + + if (boundTextElement || isTextElement(orig)) { const updatedElement = { - ...element.latest, + ...latest, width, height, }; const metrics = measureFontSizeFromWidth( - boundTextElement ?? (element.orig as ExcalidrawTextElement), + boundTextElement ?? (orig as ExcalidrawTextElement), boundTextElement ? getBoundTextMaxWidth(updatedElement) : updatedElement.width, @@ -729,29 +816,50 @@ const resizeMultipleElements = ( return; } - if (isTextElement(element.orig)) { + if (isTextElement(orig)) { update.fontSize = metrics.size; update.baseline = metrics.baseline; } if (boundTextElement) { - boundTextUpdates = { + boundText = { + element: boundTextElement, fontSize: metrics.size, baseline: metrics.baseline, }; } } - updateBoundElements(element.latest, { newSize: { width, height } }); + elementsAndUpdates.push({ element: latest, update, boundText }); + } - mutateElement(element.latest, update); + const elementsToUpdate = elementsAndUpdates.map(({ element }) => element); - if (boundTextElement && boundTextUpdates) { - mutateElement(boundTextElement, boundTextUpdates); + for (const { element, update, boundText } of elementsAndUpdates) { + const { width, height, angle } = update; - handleBindTextResize(element.latest, transformHandleType); + mutateElement(element, update, false); + + updateBoundElements(element, { + simultaneouslyUpdated: elementsToUpdate, + newSize: { width, height }, + }); + + if (boundText) { + const { element: boundTextElement, ...boundTextUpdates } = boundText; + mutateElement( + boundTextElement, + { + ...boundTextUpdates, + angle: isLinearElement(element) ? undefined : angle, + }, + false, + ); + handleBindTextResize(element, transformHandleType); } - }); + } + + Scene.getScene(elementsAndUpdates[0].element)?.informMutation(); }; const rotateMultipleElements = ( diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 5e3a5b2214..6103c2f51b 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -197,7 +197,6 @@ Object { "keyTest": [Function], "name": "flipHorizontal", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -207,7 +206,6 @@ Object { "keyTest": [Function], "name": "flipVertical", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -4594,7 +4592,6 @@ Object { "keyTest": [Function], "name": "flipHorizontal", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -4604,7 +4601,6 @@ Object { "keyTest": [Function], "name": "flipVertical", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -5144,7 +5140,6 @@ Object { "keyTest": [Function], "name": "flipHorizontal", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -5154,7 +5149,6 @@ Object { "keyTest": [Function], "name": "flipVertical", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -6003,7 +5997,6 @@ Object { "keyTest": [Function], "name": "flipHorizontal", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -6013,7 +6006,6 @@ Object { "keyTest": [Function], "name": "flipVertical", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -6349,7 +6341,6 @@ Object { "keyTest": [Function], "name": "flipHorizontal", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, @@ -6359,7 +6350,6 @@ Object { "keyTest": [Function], "name": "flipVertical", "perform": [Function], - "predicate": [Function], "trackEvent": Object { "category": "element", }, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index 68d87ef33c..931a2db5a3 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -15332,7 +15332,10 @@ Object { "penMode": false, "pendingImageElementId": null, "previousSelectedElementIds": Object { + "id0": true, + "id1": true, "id2": true, + "id3": true, }, "resizingElement": null, "scrollX": 0, @@ -15342,7 +15345,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, "id5": true, }, "selectedGroupIds": Object {}, @@ -15390,7 +15392,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 10, "x": 10, "y": 10, @@ -15421,7 +15423,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 23633383, + "versionNonce": 493213705, "width": 10, "x": 30, "y": 10, @@ -15452,7 +15454,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 493213705, + "versionNonce": 915032327, "width": 10, "x": 50, "y": 10, @@ -15803,7 +15805,6 @@ Object { "id0": true, "id1": true, "id2": true, - "id3": true, "id5": true, }, "selectedGroupIds": Object {}, @@ -15833,7 +15834,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1505387817, + "versionNonce": 23633383, "width": 10, "x": 10, "y": 10, @@ -15861,7 +15862,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 23633383, + "versionNonce": 493213705, "width": 10, "x": 30, "y": 10, @@ -15889,7 +15890,7 @@ Object { "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 493213705, + "versionNonce": 915032327, "width": 10, "x": 50, "y": 10, diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 9b4007b034..9e89996af0 100644 --- a/src/tests/contextmenu.test.tsx +++ b/src/tests/contextmenu.test.tsx @@ -207,6 +207,8 @@ describe("contextMenu element", () => { "deleteSelectedElements", "group", "addToLibrary", + "flipHorizontal", + "flipVertical", "sendBackward", "bringForward", "sendToBack", @@ -258,6 +260,8 @@ describe("contextMenu element", () => { "deleteSelectedElements", "ungroup", "addToLibrary", + "flipHorizontal", + "flipVertical", "sendBackward", "bringForward", "sendToBack", diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index c1469bc831..091d1c73b4 100644 --- a/src/tests/flip.test.tsx +++ b/src/tests/flip.test.tsx @@ -195,10 +195,8 @@ const checkElementsBoundingBox = async ( debugger; await waitFor(() => { // Check if width and height did not change - expect(x1 - toleranceInPx <= x12 && x12 <= x1 + toleranceInPx).toBeTruthy(); - expect(y1 - toleranceInPx <= y12 && y12 <= y1 + toleranceInPx).toBeTruthy(); - expect(x2 - toleranceInPx <= x22 && x22 <= x2 + toleranceInPx).toBeTruthy(); - expect(y2 - toleranceInPx <= y22 && y22 <= y2 + toleranceInPx).toBeTruthy(); + expect(x2 - x1).toBeCloseTo(x22 - x12, -1); + expect(y2 - y1).toBeCloseTo(y22 - y12, -1); }); }; @@ -216,14 +214,22 @@ const checkTwoPointsLineHorizontalFlip = async () => { h.app.actionManager.executeAction(actionFlipHorizontal); const newElement = h.elements[0] as ExcalidrawLinearElement; await waitFor(() => { - expect(originalElement.points[0][0]).toEqual( - newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0, + expect(originalElement.points[0][0]).toBeCloseTo( + -newElement.points[0][0], + 5, ); - expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]); - expect(originalElement.points[1][0]).toEqual( - newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0, + expect(originalElement.points[0][1]).toBeCloseTo( + newElement.points[0][1], + 5, + ); + expect(originalElement.points[1][0]).toBeCloseTo( + -newElement.points[1][0], + 5, + ); + expect(originalElement.points[1][1]).toBeCloseTo( + newElement.points[1][1], + 5, ); - expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]); }); }; @@ -234,14 +240,22 @@ const checkTwoPointsLineVerticalFlip = async () => { h.app.actionManager.executeAction(actionFlipVertical); const newElement = h.elements[0] as ExcalidrawLinearElement; await waitFor(() => { - expect(originalElement.points[0][0]).toEqual( - newElement.points[0][0] !== 0 ? -newElement.points[0][0] : 0, + expect(originalElement.points[0][0]).toBeCloseTo( + newElement.points[0][0], + 5, ); - expect(originalElement.points[0][1]).toEqual(newElement.points[0][1]); - expect(originalElement.points[1][0]).toEqual( - newElement.points[1][0] !== 0 ? -newElement.points[1][0] : 0, + expect(originalElement.points[0][1]).toBeCloseTo( + -newElement.points[0][1], + 5, + ); + expect(originalElement.points[1][0]).toBeCloseTo( + newElement.points[1][0], + 5, + ); + expect(originalElement.points[1][1]).toBeCloseTo( + -newElement.points[1][1], + 5, ); - expect(originalElement.points[1][1]).toEqual(newElement.points[1][1]); }); }; @@ -318,7 +332,7 @@ describe("rectangle", () => { it("flips a rotated rectangle vertically correctly", async () => { const originalAngle = (3 * Math.PI) / 4; - const expectedAgnle = Math.PI / 4; + const expectedAgnle = (5 * Math.PI) / 4; createAndSelectOneRectangle(originalAngle); @@ -351,7 +365,7 @@ describe("diamond", () => { it("flips a rotated diamond vertically correctly", async () => { const originalAngle = (5 * Math.PI) / 4; - const expectedAngle = (7 * Math.PI) / 4; + const expectedAngle = (3 * Math.PI) / 4; createAndSelectOneDiamond(originalAngle); @@ -384,7 +398,7 @@ describe("ellipse", () => { it("flips a rotated ellipse vertically correctly", async () => { const originalAngle = (7 * Math.PI) / 4; - const expectedAngle = (5 * Math.PI) / 4; + const expectedAngle = Math.PI / 4; createAndSelectOneEllipse(originalAngle); @@ -429,7 +443,7 @@ describe("arrow", () => { it("flips a rotated arrow vertically with line inside min/max points bounds", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("arrow"); h.app.scene.replaceAllElements([line]); h.app.state.selectedElementIds[line.id] = true; @@ -481,7 +495,7 @@ describe("arrow", () => { //TODO: elements with curve outside minMax points have a wrong bounding box!!! it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow"); mutateElement(line, { angle: originalAngle }); h.app.scene.replaceAllElements([line]); @@ -512,7 +526,6 @@ describe("arrow", () => { it("flips a two points arrow vertically correctly", async () => { createAndSelectOneArrow(); - await checkTwoPointsLineVerticalFlip(); }); }); @@ -581,7 +594,7 @@ describe("line", () => { //TODO: elements with curve outside minMax points have a wrong bounding box it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementsWithCurveOutsideMinMaxPoints("line"); mutateElement(line, { angle: originalAngle }); h.app.scene.replaceAllElements([line]); @@ -616,7 +629,7 @@ describe("line", () => { it("flips a rotated line vertically with line inside min/max points bounds", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; const line = createLinearElementWithCurveInsideMinMaxPoints("line"); h.app.scene.replaceAllElements([line]); h.app.state.selectedElementIds[line.id] = true; @@ -670,7 +683,7 @@ describe("freedraw", () => { it("flips a rotated drawing vertically correctly", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; const draw = createAndReturnOneDraw(originalAngle); // select draw, since not done automatically @@ -718,8 +731,8 @@ describe("image", () => { }); await checkVerticalFlip(); - expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]); - expect(h.elements[0].angle).toBeCloseTo(Math.PI); + expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]); + expect(h.elements[0].angle).toBeCloseTo(0); }); it("flips an rotated image horizontally correctly", async () => { @@ -742,7 +755,7 @@ describe("image", () => { it("flips an rotated image vertically correctly", async () => { const originalAngle = Math.PI / 4; - const expectedAngle = (3 * Math.PI) / 4; + const expectedAngle = (7 * Math.PI) / 4; //paste image await createImage(); await waitFor(() => { @@ -757,7 +770,7 @@ describe("image", () => { }); await checkRotatedVerticalFlip(expectedAngle); - expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, 1]); + expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, -1]); expect(h.elements[0].angle).toBeCloseTo(expectedAngle); }); @@ -772,7 +785,7 @@ describe("image", () => { }); await checkVerticalHorizontalFlip(); - expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([1, 1]); - expect(h.elements[0].angle).toBeCloseTo(Math.PI); + expect((h.elements[0] as ExcalidrawImageElement).scale).toEqual([-1, -1]); + expect(h.elements[0].angle).toBeCloseTo(0); }); }); diff --git a/src/tests/regressionTests.test.tsx b/src/tests/regressionTests.test.tsx index 9ecbfb42ce..2b90948887 100644 --- a/src/tests/regressionTests.test.tsx +++ b/src/tests/regressionTests.test.tsx @@ -542,7 +542,7 @@ describe("regression tests", () => { expect(element.groupIds.length).toBe(1); } - mouse.reset(); + mouse.moveTo(-10, -10); // the NW resizing handle is at [0, 0], so moving further mouse.down(); mouse.restorePosition(...end); mouse.up(); From 08563e7d7bd30db16d09da47b40b8faae176d998 Mon Sep 17 00:00:00 2001 From: Are Date: Fri, 26 May 2023 16:16:55 +0200 Subject: [PATCH 07/16] feat: update design of ImageExportDialog (#6614) Co-authored-by: dwelle --- src/actions/actionExport.tsx | 3 +- src/actions/manager.tsx | 7 +- src/components/ConfirmDialog.tsx | 2 +- src/components/Dialog.scss | 29 ++ src/components/Dialog.tsx | 33 +- src/components/ErrorDialog.tsx | 2 +- src/components/ImageExportDialog.scss | 215 ++++++++++++ src/components/ImageExportDialog.tsx | 360 +++++++++++++------- src/components/LibraryMenuHeaderContent.tsx | 2 +- src/components/Modal.scss | 19 +- src/components/Modal.tsx | 2 +- src/components/PasteChartDialog.tsx | 2 +- src/components/ProjectName.tsx | 5 +- src/components/RadioGroup.scss | 100 ++++++ src/components/RadioGroup.tsx | 42 +++ src/components/Switch.scss | 116 +++++++ src/components/Switch.tsx | 38 +++ src/components/icons.tsx | 29 ++ src/excalidraw-app/collab/RoomDialog.tsx | 2 +- src/locales/en.json | 33 +- src/tests/packages/excalidraw.test.tsx | 11 +- 21 files changed, 881 insertions(+), 171 deletions(-) create mode 100644 src/components/ImageExportDialog.scss create mode 100644 src/components/RadioGroup.scss create mode 100644 src/components/RadioGroup.tsx create mode 100644 src/components/Switch.scss create mode 100644 src/components/Switch.tsx diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index f142eac87d..d945ba9592 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -26,7 +26,7 @@ export const actionChangeProjectName = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, name: value }, commitToHistory: false }; }, - PanelComponent: ({ appState, updateData, appProps }) => ( + PanelComponent: ({ appState, updateData, appProps, data }) => ( ), }); diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 60648e4108..e52e91da73 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -118,10 +118,13 @@ export class ActionManager { return true; } - executeAction(action: Action, source: ActionSource = "api") { + executeAction( + action: Action, + source: ActionSource = "api", + value: any = null, + ) { const elements = this.getElementsIncludingDeleted(); const appState = this.getAppState(); - const value = null; trackAction(action, source, appState, elements, this.app, value); diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index a0f257e365..9061fefa07 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -31,7 +31,7 @@ const ConfirmDialog = (props: Props) => { return ( diff --git a/src/components/Dialog.scss b/src/components/Dialog.scss index 604b3c64e3..405f9235a7 100644 --- a/src/components/Dialog.scss +++ b/src/components/Dialog.scss @@ -14,4 +14,33 @@ padding: 0 0 0.75rem; margin-bottom: 1.5rem; } + + .Dialog__close { + color: var(--color-gray-40); + margin: 0; + position: absolute; + top: 0.75rem; + right: 0.5rem; + border: 0; + background-color: transparent; + line-height: 0; + cursor: pointer; + + &:hover { + color: var(--color-gray-60); + } + &:active { + color: var(--color-gray-40); + } + + @include isMobile { + top: 1.25rem; + right: 1.25rem; + } + + svg { + width: 1.5rem; + height: 1.5rem; + } + } } diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 363bb849f7..a76f65e46f 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -21,9 +21,9 @@ import { jotaiScope } from "../jotai"; export interface DialogProps { children: React.ReactNode; className?: string; - small?: boolean; + size?: "small" | "regular" | "wide"; onCloseRequest(): void; - title: React.ReactNode; + title: React.ReactNode | false; autofocus?: boolean; theme?: AppState["theme"]; closeOnClickOutside?: boolean; @@ -33,6 +33,7 @@ export const Dialog = (props: DialogProps) => { const [islandNode, setIslandNode] = useCallbackRefState(); const [lastActiveElement] = useState(document.activeElement); const { id } = useExcalidrawContainer(); + const device = useDevice(); useEffect(() => { if (!islandNode) { @@ -86,23 +87,27 @@ export const Dialog = (props: DialogProps) => { -

- {props.title} - -

+ {props.title && ( +

+ {props.title} +

+ )} +
{props.children}
diff --git a/src/components/ErrorDialog.tsx b/src/components/ErrorDialog.tsx index 56c303c157..74d265f735 100644 --- a/src/components/ErrorDialog.tsx +++ b/src/components/ErrorDialog.tsx @@ -28,7 +28,7 @@ export const ErrorDialog = ({ <> {modalIsShown && ( diff --git a/src/components/ImageExportDialog.scss b/src/components/ImageExportDialog.scss new file mode 100644 index 0000000000..a74cfdc20e --- /dev/null +++ b/src/components/ImageExportDialog.scss @@ -0,0 +1,215 @@ +@import "../css/variables.module"; + +.excalidraw { + --ImageExportModal-preview-border: #d6d6d6; + + &.theme--dark { + --ImageExportModal-preview-border: #5c5c5c; + } + + .ImageExportModal { + display: flex; + flex-direction: row; + justify-content: space-between; + + & h3 { + font-family: "Assistant"; + font-style: normal; + font-weight: 700; + font-size: 1.313rem; + line-height: 130%; + padding: 0; + margin: 0; + + @include isMobile { + display: none; + } + } + + & > h3 { + display: none; + + @include isMobile { + display: block; + } + } + + @include isMobile { + flex-direction: column; + height: calc(100vh - 5rem); + } + + &__preview { + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + height: 360px; + width: 55%; + + margin-right: 1.5rem; + + @include isMobile { + max-width: unset; + margin-right: unset; + + width: 100%; + height: unset; + flex-grow: 1; + } + + &__filename { + & > input { + margin-top: 1rem; + } + } + + &__canvas { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + flex-grow: 1; + justify-content: center; + align-items: center; + + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") + left center; + + border: 1px solid var(--ImageExportModal-preview-border); + border-radius: 12px; + + overflow: hidden; + padding: 1rem; + + & > canvas { + max-width: calc(100% - 2rem); + max-height: calc(100% - 2rem); + + filter: none !important; + + @include isMobile { + max-height: 100%; + } + } + + @include isMobile { + margin-top: 24px; + max-width: unset; + } + } + } + + &__settings { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 18px; + + @include isMobile { + margin-left: unset; + margin-top: 1rem; + flex-direction: row; + gap: 6px 34px; + + align-content: flex-start; + } + + &__setting { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + @include isMobile { + flex-direction: column; + align-items: start; + justify-content: unset; + height: 52px; + } + + &__label { + display: flex; + flex-direction: row; + align-items: center; + + font-family: "Assistant"; + font-weight: 600; + font-size: 1rem; + line-height: 150%; + + & svg { + width: 20px; + height: 20px; + margin-left: 10px; + } + } + + &__content { + display: flex; + height: 100%; + align-items: center; + } + } + + &__buttons { + flex-grow: 1; + flex-wrap: wrap; + display: flex; + flex-direction: row; + gap: 11px; + + align-items: flex-end; + align-content: flex-end; + + @include isMobile { + padding-top: 32px; + flex-basis: 100%; + justify-content: center; + } + + &__button { + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 8px 16px; + flex-shrink: 0; + width: fit-content; + gap: 8px; + + height: 40px; + border: 0; + border-radius: 8px; + + user-select: none; + font-family: "Assistant"; + font-style: normal; + font-weight: 600; + font-size: 0.75rem; + line-height: 100%; + transition: 150ms ease-out; + transition-property: background, color; + + background: var(--color-primary); + color: var(--color-icon-white); + + &:hover { + background: var(--color-primary-darker); + color: var(--color-icon-white); + } + + &:active { + background: var(--color-primary-darkest); + } + + & > svg { + width: 20px; + height: 20px; + } + } + } + } + } +} diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 38467d6eb1..604f50b4f3 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -1,25 +1,39 @@ import React, { useEffect, useRef, useState } from "react"; + +import type { ActionManager } from "../actions/manager"; +import type { AppClassProperties, BinaryFiles, UIAppState } from "../types"; + +import { + actionExportWithDarkMode, + actionChangeExportBackground, + actionChangeExportEmbedScene, + actionChangeExportScale, + actionChangeProjectName, +} from "../actions/actionExport"; import { probablySupportsClipboardBlob } from "../clipboard"; -import { canvasToBlob } from "../data/blob"; -import { NonDeletedExcalidrawElement } from "../element/types"; -import { t } from "../i18n"; -import { getSelectedElements, isSomeElementSelected } from "../scene"; -import { AppClassProperties, BinaryFiles, UIAppState } from "../types"; -import { Dialog } from "./Dialog"; -import { clipboard } from "./icons"; -import Stack from "./Stack"; -import OpenColor from "open-color"; -import { CheckboxItem } from "./CheckboxItem"; import { DEFAULT_EXPORT_PADDING, EXPORT_IMAGE_TYPES, isFirefox, + EXPORT_SCALES, } from "../constants"; + +import { canvasToBlob } from "../data/blob"; import { nativeFileSystemSupported } from "../data/filesystem"; -import { ActionManager } from "../actions/manager"; +import { NonDeletedExcalidrawElement } from "../element/types"; +import { t } from "../i18n"; +import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../packages/utils"; -import "./ExportDialog.scss"; +import { copyIcon, downloadIcon, helpIcon } from "./icons"; +import { Button } from "./Button"; +import { Dialog } from "./Dialog"; +import { RadioGroup } from "./RadioGroup"; +import { Switch } from "./Switch"; +import { Tooltip } from "./Tooltip"; + +import "./ImageExportDialog.scss"; +import { useAppProps } from "./App"; const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -36,50 +50,36 @@ export const ErrorCanvasPreview = () => { ); }; -export type ExportCB = ( - elements: readonly NonDeletedExcalidrawElement[], - scale?: number, -) => void; - -const ExportButton: React.FC<{ - color: keyof OpenColor; - onClick: () => void; - title: string; - shade?: number; - children?: React.ReactNode; -}> = ({ children, title, onClick, color, shade = 6 }) => { - return ( - - ); -}; - -const ImageExportModal = ({ - elements, - appState, - files, - actionManager, - onExportImage, -}: { +type ImageExportModalProps = { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles; actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; -}) => { +}; + +const ImageExportModal = ({ + appState, + elements, + files, + actionManager, + onExportImage, +}: ImageExportModalProps) => { + const appProps = useAppProps(); + const [projectName, setProjectName] = useState(appState.name); + const someElementIsSelected = isSomeElementSelected(elements, appState); + const [exportSelected, setExportSelected] = useState(someElementIsSelected); + const [exportWithBackground, setExportWithBackground] = useState( + appState.exportBackground, + ); + const [exportDarkMode, setExportDarkMode] = useState( + appState.exportWithDarkMode, + ); + const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene); + const [exportScale, setExportScale] = useState(appState.exportScale); + const previewRef = useRef(null); const [renderError, setRenderError] = useState(null); @@ -93,6 +93,7 @@ const ImageExportModal = ({ return; } const maxWidth = previewNode.offsetWidth; + const maxHeight = previewNode.offsetHeight; if (!maxWidth) { return; } @@ -101,7 +102,7 @@ const ImageExportModal = ({ appState, files, exportPadding: DEFAULT_EXPORT_PADDING, - maxWidthOrHeight: maxWidth, + maxWidthOrHeight: Math.max(maxWidth, maxHeight), }) .then((canvas) => { setRenderError(null); @@ -118,89 +119,190 @@ const ImageExportModal = ({ }, [appState, files, exportedElements]); return ( -
-
- {renderError && } -
- {supportsContextFilters && - actionManager.renderAction("exportWithDarkMode")} -
-
- {actionManager.renderAction("changeExportBackground")} - {someElementIsSelected && ( - setExportSelected(checked)} - > - {t("labels.onlySelected")} - +
+

{t("imageExportDialog.header")}

+
+
+ {renderError && } +
+
+ {!nativeFileSystemSupported && ( + { + setProjectName(event.target.value); + actionManager.executeAction( + actionChangeProjectName, + "ui", + event.target.value, + ); + }} + /> )} - {actionManager.renderAction("changeExportEmbedScene")}
-
- - {actionManager.renderAction("changeExportScale")} - -

- {t("buttons.scale")} -

-
-
- {!nativeFileSystemSupported && - actionManager.renderAction("changeProjectName")} -
- - - onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements) - } - > - PNG - - - onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements) - } - > - SVG - - {/* firefox supports clipboard API under a flag, - so let's throw and tell people what they can do */} - {(probablySupportsClipboardBlob || isFirefox) && ( - - onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements) - } - color="gray" - shade={7} +
+

{t("imageExportDialog.header")}

+ {someElementIsSelected && ( + - {clipboard} - + { + setExportSelected(checked); + }} + /> + )} - + + { + setExportWithBackground(checked); + actionManager.executeAction( + actionChangeExportBackground, + "ui", + checked, + ); + }} + /> + + {supportsContextFilters && ( + + { + setExportDarkMode(checked); + actionManager.executeAction( + actionExportWithDarkMode, + "ui", + checked, + ); + }} + /> + + )} + + { + setEmbedScene(checked); + actionManager.executeAction( + actionChangeExportEmbedScene, + "ui", + checked, + ); + }} + /> + + + { + setExportScale(scale); + actionManager.executeAction(actionChangeExportScale, "ui", scale); + }} + choices={EXPORT_SCALES.map((scale) => ({ + value: scale, + label: `${scale}\u00d7`, + }))} + /> + + +
+ + + {(probablySupportsClipboardBlob || isFirefox) && ( + + )} +
+
+
+ ); +}; + +type ExportSettingProps = { + label: string; + children: React.ReactNode; + tooltip?: string; + name?: string; +}; + +const ExportSetting = ({ + label, + children, + tooltip, + name, +}: ExportSettingProps) => { + return ( +
+ +
+ {children} +
); }; @@ -225,7 +327,7 @@ export const ImageExportDialog = ({ } return ( - + setPublishLibSuccess(null)} title={t("publishSuccessDialog.title")} className="publish-library-success" - small={true} + size="small" >

+ />
void; label: string; isNameEditable: boolean; + ignoreFocus?: boolean; }; export const ProjectName = (props: Props) => { @@ -19,7 +20,9 @@ export const ProjectName = (props: Props) => { const [fileName, setFileName] = useState(props.value); const handleBlur = (event: any) => { - focusNearestParent(event.target); + if (!props.ignoreFocus) { + focusNearestParent(event.target); + } const value = event.target.value; if (value !== props.value) { props.onChange(value); diff --git a/src/components/RadioGroup.scss b/src/components/RadioGroup.scss new file mode 100644 index 0000000000..8606482693 --- /dev/null +++ b/src/components/RadioGroup.scss @@ -0,0 +1,100 @@ +@import "../css/variables.module"; + +.excalidraw { + --RadioGroup-background: #ffffff; + --RadioGroup-border: var(--color-gray-30); + + --RadioGroup-choice-color-off: var(--color-primary); + --RadioGroup-choice-color-off-hover: var(--color-primary-darkest); + --RadioGroup-choice-background-off: white; + --RadioGroup-choice-background-off-active: var(--color-gray-20); + + --RadioGroup-choice-color-on: white; + --RadioGroup-choice-background-on: var(--color-primary); + --RadioGroup-choice-background-on-hover: var(--color-primary-darker); + --RadioGroup-choice-background-on-active: var(--color-primary-darkest); + + &.theme--dark { + --RadioGroup-background: var(--color-gray-85); + --RadioGroup-border: var(--color-gray-70); + + --RadioGroup-choice-background-off: var(--color-gray-85); + --RadioGroup-choice-background-off-active: var(--color-gray-70); + --RadioGroup-choice-color-on: var(--color-gray-85); + } + + .RadioGroup { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: flex-start; + + padding: 3px; + border-radius: 10px; + + background: var(--RadioGroup-background); + border: 1px solid var(--RadioGroup-border); + + &__choice { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 24px; + + color: var(--RadioGroup-choice-color-off); + background: var(--RadioGroup-choice-background-off); + + border-radius: 8px; + + font-family: "Assistant"; + font-style: normal; + font-weight: 600; + font-size: 0.75rem; + line-height: 100%; + user-select: none; + letter-spacing: 0.4px; + + transition: all 75ms ease-out; + + &:hover { + color: var(--RadioGroup-choice-color-off-hover); + } + + &:active { + background: var(--RadioGroup-choice-background-off-active); + } + + &.active { + color: var(--RadioGroup-choice-color-on); + background: var(--RadioGroup-choice-background-on); + + &:hover { + background: var(--RadioGroup-choice-background-on-hover); + } + + &:active { + background: var(--RadioGroup-choice-background-on-active); + } + } + + & input { + z-index: 1; + position: absolute; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + + border-radius: 8px; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + cursor: pointer; + } + } + } +} diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx new file mode 100644 index 0000000000..40c6551f14 --- /dev/null +++ b/src/components/RadioGroup.tsx @@ -0,0 +1,42 @@ +import clsx from "clsx"; +import "./RadioGroup.scss"; + +export type RadioGroupChoice = { + value: T; + label: string; +}; + +export type RadioGroupProps = { + choices: RadioGroupChoice[]; + value: T; + onChange: (value: T) => void; + name: string; +}; + +export const RadioGroup = function ({ + onChange, + value, + choices, + name, +}: RadioGroupProps) { + return ( +
+ {choices.map((choice) => ( +
+ onChange(choice.value)} + /> + {choice.label} +
+ ))} +
+ ); +}; diff --git a/src/components/Switch.scss b/src/components/Switch.scss new file mode 100644 index 0000000000..ab98bad639 --- /dev/null +++ b/src/components/Switch.scss @@ -0,0 +1,116 @@ +@import "../css/variables.module"; + +.excalidraw { + --Switch-disabled-color: #d6d6d6; + --Switch-track-background: white; + --Switch-thumb-background: #3d3d3d; + + &.theme--dark { + --Switch-disabled-color: #5c5c5c; + --Switch-track-background: #242424; + --Switch-thumb-background: #b8b8b8; + } + + .Switch { + position: relative; + box-sizing: border-box; + + width: 40px; + height: 20px; + border-radius: 12px; + + transition-property: background, border; + transition-duration: 150ms; + transition-timing-function: ease-out; + + background: var(--Switch-track-background); + border: 1px solid var(--Switch-disabled-color); + + &:hover { + background: var(--Switch-track-background); + border: 1px solid #999999; + } + + &.toggled { + background: var(--color-primary); + border: 1px solid var(--color-primary); + + &:hover { + background: var(--color-primary-darker); + border: 1px solid var(--color-primary-darker); + } + } + + &.disabled { + background: var(--Switch-track-background); + border: 1px solid var(--Switch-disabled-color); + + &.toggled { + background: var(--Switch-disabled-color); + border: 1px solid var(--Switch-disabled-color); + } + } + + &:before { + content: ""; + box-sizing: border-box; + display: block; + pointer-events: none; + position: absolute; + + border-radius: 100%; + transition: all 150ms ease-out; + + width: 10px; + height: 10px; + top: 4px; + left: 4px; + + background: var(--Switch-thumb-background); + } + + &:active:before { + width: 12px; + } + + &.toggled:before { + width: 14px; + height: 14px; + left: 22px; + top: 2px; + + background: var(--Switch-track-background); + } + + &.toggled:active:before { + width: 16px; + left: 20px; + } + + &.disabled:before { + background: var(--Switch-disabled-color); + } + + &.disabled.toggled:before { + background: var(--color-gray-50); + } + + & input { + width: 100%; + height: 100%; + margin: 0; + + border-radius: 12px; + + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + + cursor: pointer; + + &:disabled { + cursor: unset; + } + } + } +} diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx new file mode 100644 index 0000000000..dfbf332fce --- /dev/null +++ b/src/components/Switch.tsx @@ -0,0 +1,38 @@ +import clsx from "clsx"; + +import "./Switch.scss"; + +export type SwitchProps = { + name: string; + checked: boolean; + title?: string; + onChange: (value: boolean) => void; + disabled?: boolean; +}; + +export const Switch = ({ + title, + name, + checked, + onChange, + disabled = false, +}: SwitchProps) => { + return ( +
+ onChange(!checked)} + onKeyDown={(e) => { + if (e.key === " ") { + onChange(!checked); + } + }} + /> +
+ ); +}; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 784e810248..3841d030e6 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1550,3 +1550,32 @@ export const handIcon = createIcon( , tablerIconProps, ); + +export const downloadIcon = createIcon( + <> + + + + + , + tablerIconProps, +); + +export const copyIcon = createIcon( + <> + + + + , + tablerIconProps, +); + +export const helpIcon = createIcon( + <> + + + + + , + tablerIconProps, +); diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index 4810b5a559..05772774ad 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -180,7 +180,7 @@ const RoomDialog = ({ }; return ( ", () => { toggleMenu(container); fireEvent.click(queryByTestId(container, "image-export-button")!); const textInput: HTMLInputElement | null = document.querySelector( - ".ExportDialog .ProjectName .TextInput", + ".ImageExportModal .ImageExportModal__preview__filename .TextInput", ); expect(textInput?.value).toContain(`${t("labels.untitled")}`); expect(textInput?.nodeName).toBe("INPUT"); @@ -303,10 +303,11 @@ describe("", () => { toggleMenu(container); await fireEvent.click(queryByTestId(container, "image-export-button")!); const textInput = document.querySelector( - ".ExportDialog .ProjectName .TextInput--readonly", - ); - expect(textInput?.textContent).toEqual(name); - expect(textInput?.nodeName).toBe("SPAN"); + ".ImageExportModal .ImageExportModal__preview__filename .TextInput", + ) as HTMLInputElement; + expect(textInput?.value).toEqual(name); + expect(textInput?.nodeName).toBe("INPUT"); + expect(textInput?.disabled).toBe(true); }); }); From a91e401554388b72741efc19b27478f7964d939a Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Mon, 29 May 2023 16:01:44 +0200 Subject: [PATCH 08/16] feat: clearing library cache (#6621) Co-authored-by: dwelle --- src/components/App.tsx | 1 + src/components/LibraryMenuHeaderContent.tsx | 63 +++++++++++---------- src/components/LibraryMenuItems.tsx | 4 ++ src/components/LibraryMenuSection.tsx | 9 ++- src/components/LibraryUnit.tsx | 6 +- src/data/library.ts | 15 +++++ src/hooks/useLibraryItemSvg.ts | 29 ++++++++-- 7 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 49919c957c..0b730fc47a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1072,6 +1072,7 @@ class App extends React.Component { this.unmounted = true; this.removeEventListeners(); this.scene.destroy(); + this.library.destroy(); clearTimeout(touchTimeout); touchTimeout = 0; } diff --git a/src/components/LibraryMenuHeaderContent.tsx b/src/components/LibraryMenuHeaderContent.tsx index d51f651f34..56c8fe6d0c 100644 --- a/src/components/LibraryMenuHeaderContent.tsx +++ b/src/components/LibraryMenuHeaderContent.tsx @@ -24,6 +24,7 @@ import DropdownMenu from "./dropdownMenu/DropdownMenu"; import { isLibraryMenuOpenAtom } from "./LibraryMenu"; import { useUIAppState } from "../context/ui-appState"; import clsx from "clsx"; +import { useLibraryCache } from "../hooks/useLibraryItemSvg"; const getSelectedItems = ( libraryItems: LibraryItems, @@ -55,7 +56,7 @@ export const LibraryDropdownMenuButton: React.FC<{ jotaiScope, ); - const renderRemoveLibAlert = useCallback(() => { + const renderRemoveLibAlert = () => { const content = selectedItems.length ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length }) : t("alerts.resetLibrary"); @@ -80,7 +81,7 @@ export const LibraryDropdownMenuButton: React.FC<{

{content}

); - }, [selectedItems, onRemoveFromLibrary, resetLibrary]); + }; const [showRemoveLibAlert, setShowRemoveLibAlert] = useState(false); @@ -136,20 +137,20 @@ export const LibraryDropdownMenuButton: React.FC<{ ); }, [setPublishLibSuccess, publishLibSuccess]); - const onPublishLibSuccess = useCallback( - (data: { url: string; authorName: string }, libraryItems: LibraryItems) => { - setShowPublishLibraryDialog(false); - setPublishLibSuccess({ url: data.url, authorName: data.authorName }); - const nextLibItems = libraryItems.slice(); - nextLibItems.forEach((libItem) => { - if (selectedItems.includes(libItem.id)) { - libItem.status = "published"; - } - }); - library.setLibrary(nextLibItems); - }, - [setShowPublishLibraryDialog, setPublishLibSuccess, selectedItems, library], - ); + const onPublishLibSuccess = ( + data: { url: string; authorName: string }, + libraryItems: LibraryItems, + ) => { + setShowPublishLibraryDialog(false); + setPublishLibSuccess({ url: data.url, authorName: data.authorName }); + const nextLibItems = libraryItems.slice(); + nextLibItems.forEach((libItem) => { + if (selectedItems.includes(libItem.id)) { + libItem.status = "published"; + } + }); + library.setLibrary(nextLibItems); + }; const onLibraryImport = async () => { try { @@ -280,27 +281,29 @@ export const LibraryDropdownMenu = ({ className?: string; }) => { const { library } = useApp(); + const { clearLibraryCache, deleteItemsFromLibraryCache } = useLibraryCache(); const appState = useUIAppState(); const setAppState = useExcalidrawSetAppState(); const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - const removeFromLibrary = useCallback( - async (libraryItems: LibraryItems) => { - const nextItems = libraryItems.filter( - (item) => !selectedItems.includes(item.id), - ); - library.setLibrary(nextItems).catch(() => { - setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); - }); - onSelectItems([]); - }, - [library, setAppState, selectedItems, onSelectItems], - ); + const removeFromLibrary = async (libraryItems: LibraryItems) => { + const nextItems = libraryItems.filter( + (item) => !selectedItems.includes(item.id), + ); + library.setLibrary(nextItems).catch(() => { + setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") }); + }); - const resetLibrary = useCallback(() => { + deleteItemsFromLibraryCache(selectedItems); + + onSelectItems([]); + }; + + const resetLibrary = () => { library.resetLibrary(); - }, [library]); + clearLibraryCache(); + }; return ( ([]); + const { svgCache } = useLibraryCache(); const unpublishedItems = libraryItems.filter( (item) => item.status !== "published", @@ -224,6 +226,7 @@ export default function LibraryMenuItems({ onItemDrag={onItemDrag} onClick={onItemClick} isItemSelected={isItemSelected} + svgCache={svgCache} /> )} @@ -243,6 +246,7 @@ export default function LibraryMenuItems({ onItemDrag={onItemDrag} onClick={onItemClick} isItemSelected={isItemSelected} + svgCache={svgCache} /> ) : unpublishedItems.length > 0 ? (
void; onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void; isItemSelected: (id: LibraryItem["id"] | null) => boolean; + svgCache: SvgCache; } function LibraryRow({ @@ -34,6 +34,7 @@ function LibraryRow({ onItemDrag, isItemSelected, onClick, + svgCache, }: Props) { return ( @@ -47,6 +48,7 @@ function LibraryRow({ selected={isItemSelected(item.id)} onToggle={onItemSelectToggle} onDrag={onItemDrag} + svgCache={svgCache} /> ))} @@ -68,11 +70,11 @@ function LibraryMenuSection({ onItemDrag, isItemSelected, onClick, + svgCache, }: Props) { const rows = Math.ceil(items.length / ITEMS_PER_ROW); const [, startTransition] = useTransition(); const [index, setIndex] = useState(0); - const [svgCache] = useAtom(libraryItemSvgsCache); const rowsRenderedPerBatch = useMemo(() => { return svgCache.size === 0 @@ -99,6 +101,7 @@ function LibraryMenuSection({ onItemDrag={onItemDrag} onClick={onClick} isItemSelected={isItemSelected} + svgCache={svgCache} /> ) : ( diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 68fdec1430..97f73f80a2 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -5,7 +5,7 @@ import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; -import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; +import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; export const LibraryUnit = ({ id, @@ -15,6 +15,7 @@ export const LibraryUnit = ({ selected, onToggle, onDrag, + svgCache, }: { id: LibraryItem["id"] | /** for pending item */ null; elements?: LibraryItem["elements"]; @@ -23,9 +24,10 @@ export const LibraryUnit = ({ selected: boolean; onToggle: (id: string, event: React.MouseEvent) => void; onDrag: (id: string, event: React.DragEvent) => void; + svgCache: SvgCache; }) => { const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements); + const svg = useLibraryItemSvg(id, elements, svgCache); useEffect(() => { const node = ref.current; diff --git a/src/data/library.ts b/src/data/library.ts index b9033bacec..381caed113 100644 --- a/src/data/library.ts +++ b/src/data/library.ts @@ -22,6 +22,7 @@ import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB, } from "../constants"; +import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; @@ -115,6 +116,20 @@ class Library { } }; + /** call on excalidraw instance unmount */ + destroy = () => { + this.isInitialized = false; + this.updateQueue = []; + this.lastLibraryItems = []; + jotaiStore.set(libraryItemSvgsCache, new Map()); + // TODO uncomment after/if we make jotai store scoped to each excal instance + // jotaiStore.set(libraryItemsAtom, { + // status: "loading", + // isInitialized: false, + // libraryItems: [], + // }); + }; + resetLibrary = () => { return this.setLibrary([]); }; diff --git a/src/hooks/useLibraryItemSvg.ts b/src/hooks/useLibraryItemSvg.ts index d0a0f53262..ba98021999 100644 --- a/src/hooks/useLibraryItemSvg.ts +++ b/src/hooks/useLibraryItemSvg.ts @@ -1,12 +1,13 @@ import { atom, useAtom } from "jotai"; import { useEffect, useState } from "react"; import { COLOR_PALETTE } from "../colors"; +import { jotaiScope } from "../jotai"; import { exportToSvg } from "../packages/utils"; import { LibraryItem } from "../types"; -export const libraryItemSvgsCache = atom>( - new Map(), -); +export type SvgCache = Map; + +export const libraryItemSvgsCache = atom(new Map()); const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { return await exportToSvg({ @@ -22,8 +23,8 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => { export const useLibraryItemSvg = ( id: LibraryItem["id"] | null, elements: LibraryItem["elements"] | undefined, + svgCache: SvgCache, ): SVGSVGElement | undefined => { - const [svgCache, setSvgCache] = useAtom(libraryItemSvgsCache); const [svg, setSvg] = useState(); useEffect(() => { @@ -40,7 +41,7 @@ export const useLibraryItemSvg = ( const exportedSvg = await exportLibraryItemToSvg(elements); if (exportedSvg) { - setSvgCache(svgCache.set(id, exportedSvg)); + svgCache.set(id, exportedSvg); setSvg(exportedSvg); } })(); @@ -53,7 +54,23 @@ export const useLibraryItemSvg = ( })(); } } - }, [id, elements, svgCache, setSvgCache, setSvg]); + }, [id, elements, svgCache, setSvg]); return svg; }; + +export const useLibraryCache = () => { + const [svgCache] = useAtom(libraryItemSvgsCache, jotaiScope); + + const clearLibraryCache = () => svgCache.clear(); + + const deleteItemsFromLibraryCache = (items: LibraryItem["id"][]) => { + items.forEach((item) => svgCache.delete(item)); + }; + + return { + clearLibraryCache, + deleteItemsFromLibraryCache, + svgCache, + }; +}; From 1e3c94a37ad80cb8d32dc339758eefc54ae5f108 Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Wed, 31 May 2023 10:22:02 +0200 Subject: [PATCH 09/16] feat: recover scrolled position after Library re-opening (#6624) Co-authored-by: dwelle --- src/components/LibraryMenuItems.tsx | 13 +++- src/components/LibraryMenuSection.tsx | 8 ++- src/components/LibraryUnit.scss | 35 +++++++++++ src/components/LibraryUnit.tsx | 1 + src/components/Stack.tsx | 89 +++++++++++++-------------- src/hooks/useScrollPosition.ts | 32 ++++++++++ 6 files changed, 129 insertions(+), 49 deletions(-) create mode 100644 src/hooks/useScrollPosition.ts diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 55de2aaf6a..8dd6b30f28 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; import { @@ -15,6 +15,7 @@ import { duplicateElements } from "../element/newElement"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; import LibraryMenuSection from "./LibraryMenuSection"; +import { useScrollPosition } from "../hooks/useScrollPosition"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import "./LibraryMenuItems.scss"; @@ -39,6 +40,15 @@ export default function LibraryMenuItems({ id: string; }) { const [selectedItems, setSelectedItems] = useState([]); + const libraryContainerRef = useRef(null); + const scrollPosition = useScrollPosition(libraryContainerRef); + + // This effect has to be called only on first render, therefore `scrollPosition` isn't in the dependency array + useEffect(() => { + if (scrollPosition > 0) { + libraryContainerRef.current?.scrollTo(0, scrollPosition); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); const unpublishedItems = libraryItems.filter( @@ -183,6 +193,7 @@ export default function LibraryMenuItems({ flex: publishedItems.length > 0 ? 1 : "0 1 auto", marginBottom: 0, }} + ref={libraryContainerRef} > <> {!isLibraryEmpty && ( diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx index d13c8959d6..496f045b71 100644 --- a/src/components/LibraryMenuSection.tsx +++ b/src/components/LibraryMenuSection.tsx @@ -58,9 +58,11 @@ function LibraryRow({ const EmptyLibraryRow = () => ( - -
- + {Array.from({ length: ITEMS_PER_ROW }).map((_, index) => ( + +
+ + ))} ); diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index 85d7505c38..1bfc1e6dfe 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -20,6 +20,27 @@ border-color: var(--color-primary); border-width: 1px; } + + &--skeleton { + opacity: 0.5; + background: linear-gradient( + -45deg, + var(--color-gray-10), + var(--color-gray-20), + var(--color-gray-10) + ); + background-size: 200% 200%; + animation: library-unit__skeleton-opacity-animation 0.3s linear; + } + } + + &.theme--dark .library-unit--skeleton { + background-image: linear-gradient( + -45deg, + var(--color-gray-100), + var(--color-gray-80), + var(--color-gray-100) + ); } .library-unit__dragger { @@ -142,4 +163,18 @@ transform: scale(0.85); } } + + @keyframes library-unit__skeleton-opacity-animation { + 0% { + opacity: 0; + } + + 75% { + opacity: 0; + } + + 100% { + opacity: 0.5; + } + } } diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index 97f73f80a2..f256b49f18 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -58,6 +58,7 @@ export const LibraryUnit = ({ "library-unit__active": elements, "library-unit--hover": elements && isHovered, "library-unit--selected": selected, + "library-unit--skeleton": !svg, })} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} diff --git a/src/components/Stack.tsx b/src/components/Stack.tsx index aa18e89985..c54a6aef83 100644 --- a/src/components/Stack.tsx +++ b/src/components/Stack.tsx @@ -1,6 +1,6 @@ import "./Stack.scss"; -import React from "react"; +import React, { forwardRef } from "react"; import clsx from "clsx"; type StackProps = { @@ -10,53 +10,52 @@ type StackProps = { justifyContent?: "center" | "space-around" | "space-between"; className?: string | boolean; style?: React.CSSProperties; + ref: React.RefObject; }; -const RowStack = ({ - children, - gap, - align, - justifyContent, - className, - style, -}: StackProps) => { - return ( -
- {children} -
- ); -}; +const RowStack = forwardRef( + ( + { children, gap, align, justifyContent, className, style }: StackProps, + ref: React.ForwardedRef, + ) => { + return ( +
+ {children} +
+ ); + }, +); -const ColStack = ({ - children, - gap, - align, - justifyContent, - className, - style, -}: StackProps) => { - return ( -
- {children} -
- ); -}; +const ColStack = forwardRef( + ( + { children, gap, align, justifyContent, className, style }: StackProps, + ref: React.ForwardedRef, + ) => { + return ( +
+ {children} +
+ ); + }, +); export default { Row: RowStack, diff --git a/src/hooks/useScrollPosition.ts b/src/hooks/useScrollPosition.ts new file mode 100644 index 0000000000..e4efb460ea --- /dev/null +++ b/src/hooks/useScrollPosition.ts @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { atom, useAtom } from "jotai"; +import throttle from "lodash.throttle"; + +const scrollPositionAtom = atom(0); + +export const useScrollPosition = ( + elementRef: React.RefObject, +) => { + const [scrollPosition, setScrollPosition] = useAtom(scrollPositionAtom); + + useEffect(() => { + const { current: element } = elementRef; + if (!element) { + return; + } + + const handleScroll = throttle(() => { + const { scrollTop } = element; + setScrollPosition(scrollTop); + }, 200); + + element.addEventListener("scroll", handleScroll); + + return () => { + handleScroll.cancel(); + element.removeEventListener("scroll", handleScroll); + }; + }, [elementRef, setScrollPosition]); + + return scrollPosition; +}; From 82d8d02697e534bcfbeffe7f9438ca60c72230d9 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 31 May 2023 17:30:14 +0530 Subject: [PATCH 10/16] test: Add coverage script (#6634) Add coverage script --- package.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/package.json b/package.json index 4556cb5f79..246c785a52 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,15 @@ }, "homepage": ".", "jest": { + "collectCoverageFrom": [ + "src/**/*.{js,jsx,ts,tsx}" + ], + "coveragePathIgnorePatterns": [ + "/locales", + "/src/packages/excalidraw/dist/", + "/src/packages/excalidraw/types", + "/src/packages/excalidraw/example" + ], "transformIgnorePatterns": [ "node_modules/(?!(roughjs|points-on-curve|path-data-parser|points-on-path|browser-fs-access)/)" ], @@ -128,6 +137,7 @@ "test:typecheck": "tsc", "test:update": "yarn test:app --updateSnapshot --watchAll=false", "test": "yarn test:app", + "test:coverage": "react-scripts test --passWithNoTests --coverage --watchAll", "autorelease": "node scripts/autorelease.js", "prerelease": "node scripts/prerelease.js", "release": "node scripts/release.js" From 253c5c78663314d6cb2c1d6d03a0139eddddaff9 Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Wed, 31 May 2023 15:37:13 +0200 Subject: [PATCH 11/16] perf: memoize rendering of library (#6622) Co-authored-by: dwelle --- src/components/LibraryMenu.tsx | 129 ++++++++++----- src/components/LibraryMenuItems.scss | 6 + src/components/LibraryMenuItems.tsx | 229 ++++++++++++++++---------- src/components/LibraryMenuSection.tsx | 145 ++++++---------- src/components/LibraryUnit.scss | 2 +- src/components/LibraryUnit.tsx | 171 +++++++++---------- src/hooks/useLibraryItemSvg.ts | 1 + 7 files changed, 377 insertions(+), 306 deletions(-) diff --git a/src/components/LibraryMenu.tsx b/src/components/LibraryMenu.tsx index a49f319709..f5f386d4c8 100644 --- a/src/components/LibraryMenu.tsx +++ b/src/components/LibraryMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useState, useCallback, useMemo, useRef } from "react"; import Library, { distributeLibraryItemsOnSquareGrid, libraryItemsAtom, @@ -27,6 +27,8 @@ import { useUIAppState } from "../context/ui-appState"; import "./LibraryMenu.scss"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; +import { isShallowEqual } from "../utils"; +import { NonDeletedExcalidrawElement } from "../element/types"; export const isLibraryMenuOpenAtom = atom(false); @@ -42,7 +44,9 @@ export const LibraryMenuContent = ({ libraryReturnUrl, library, id, - appState, + theme, + selectedItems, + onSelectItems, }: { pendingElements: LibraryItem["elements"]; onInsertLibraryItems: (libraryItems: LibraryItems) => void; @@ -51,33 +55,47 @@ export const LibraryMenuContent = ({ libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; library: Library; id: string; - appState: UIAppState; + theme: UIAppState["theme"]; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; }) => { const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); - const addToLibrary = useCallback( - async (elements: LibraryItem["elements"], libraryItems: LibraryItems) => { - trackEvent("element", "addToLibrary", "ui"); - if (elements.some((element) => element.type === "image")) { - return setAppState({ - errorMessage: "Support for adding images to the library coming soon!", + const _onAddToLibrary = useCallback( + (elements: LibraryItem["elements"]) => { + const addToLibrary = async ( + processedElements: LibraryItem["elements"], + libraryItems: LibraryItems, + ) => { + trackEvent("element", "addToLibrary", "ui"); + if (processedElements.some((element) => element.type === "image")) { + return setAppState({ + errorMessage: + "Support for adding images to the library coming soon!", + }); + } + const nextItems: LibraryItems = [ + { + status: "unpublished", + elements: processedElements, + id: randomId(), + created: Date.now(), + }, + ...libraryItems, + ]; + onAddToLibrary(); + library.setLibrary(nextItems).catch(() => { + setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); }); - } - const nextItems: LibraryItems = [ - { - status: "unpublished", - elements, - id: randomId(), - created: Date.now(), - }, - ...libraryItems, - ]; - onAddToLibrary(); - library.setLibrary(nextItems).catch(() => { - setAppState({ errorMessage: t("alerts.errorAddingToLibrary") }); - }); + }; + addToLibrary(elements, libraryItemsData.libraryItems); }, - [onAddToLibrary, library, setAppState], + [onAddToLibrary, library, setAppState, libraryItemsData.libraryItems], + ); + + const libraryItems = useMemo( + () => libraryItemsData.libraryItems, + [libraryItemsData], ); if ( @@ -103,15 +121,15 @@ export const LibraryMenuContent = ({ - addToLibrary(elements, libraryItemsData.libraryItems) - } + libraryItems={libraryItems} + onAddToLibrary={_onAddToLibrary} onInsertLibraryItems={onInsertLibraryItems} pendingElements={pendingElements} id={id} libraryReturnUrl={libraryReturnUrl} - theme={appState.theme} + theme={theme} + onSelectItems={onSelectItems} + selectedItems={selectedItems} /> {showBtn && ( )} ); }; +const usePendingElementsMemo = ( + appState: UIAppState, + elements: readonly NonDeletedExcalidrawElement[], +) => { + const create = () => getSelectedElements(elements, appState, true); + const val = useRef(create()); + const prevAppState = useRef(appState); + const prevElements = useRef(elements); + + if ( + !isShallowEqual( + appState.selectedElementIds, + prevAppState.current.selectedElementIds, + ) || + !isShallowEqual(elements, prevElements.current) + ) { + val.current = create(); + prevAppState.current = appState; + prevElements.current = elements; + } + return val.current; +}; + /** * This component is meant to be rendered inside inside our * or host apps Sidebar components. @@ -136,9 +177,19 @@ export const LibraryMenu = () => { const appState = useUIAppState(); const setAppState = useExcalidrawSetAppState(); const elements = useExcalidrawElements(); + const [selectedItems, setSelectedItems] = useState([]); + const memoizedLibrary = useMemo(() => library, [library]); + // BUG: pendingElements are still causing some unnecessary rerenders because clicking into canvas returns some ids even when no element is selected. + const pendingElements = usePendingElementsMemo(appState, elements); - const onAddToLibrary = useCallback(() => { - // deselect canvas elements + const onInsertLibraryItems = useCallback( + (libraryItems: LibraryItems) => { + onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + }, + [onInsertElements], + ); + + const deselectItems = useCallback(() => { setAppState({ selectedElementIds: {}, selectedGroupIds: {}, @@ -147,16 +198,16 @@ export const LibraryMenu = () => { return ( { - onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); - }} - onAddToLibrary={onAddToLibrary} + pendingElements={pendingElements} + onInsertLibraryItems={onInsertLibraryItems} + onAddToLibrary={deselectItems} setAppState={setAppState} libraryReturnUrl={appProps.libraryReturnUrl} - library={library} + library={memoizedLibrary} id={id} - appState={appState} + theme={appState.theme} + selectedItems={selectedItems} + onSelectItems={setSelectedItems} /> ); }; diff --git a/src/components/LibraryMenuItems.scss b/src/components/LibraryMenuItems.scss index 1a3fa97152..8ac09aabcb 100644 --- a/src/components/LibraryMenuItems.scss +++ b/src/components/LibraryMenuItems.scss @@ -73,6 +73,12 @@ } } + &__grid { + display: grid; + grid-template-columns: 1fr 1fr 1fr 1fr; + grid-gap: 1rem; + } + .separator { width: 100%; display: flex; diff --git a/src/components/LibraryMenuItems.tsx b/src/components/LibraryMenuItems.tsx index 8dd6b30f28..ff88e537ca 100644 --- a/src/components/LibraryMenuItems.tsx +++ b/src/components/LibraryMenuItems.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { serializeLibraryAsJSON } from "../data/json"; import { t } from "../i18n"; import { @@ -14,12 +20,22 @@ import Spinner from "./Spinner"; import { duplicateElements } from "../element/newElement"; import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons"; import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent"; -import LibraryMenuSection from "./LibraryMenuSection"; +import { + LibraryMenuSection, + LibraryMenuSectionGrid, +} from "./LibraryMenuSection"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import "./LibraryMenuItems.scss"; +// using an odd number of items per batch so the rendering creates an irregular +// pattern which looks more organic +const ITEMS_RENDERED_PER_BATCH = 17; +// when render outputs cached we can render many more items per batch to +// speed it up +const CACHED_ITEMS_RENDERED_PER_BATCH = 64; + export default function LibraryMenuItems({ isLoading, libraryItems, @@ -29,6 +45,8 @@ export default function LibraryMenuItems({ theme, id, libraryReturnUrl, + onSelectItems, + selectedItems, }: { isLoading: boolean; libraryItems: LibraryItems; @@ -38,8 +56,9 @@ export default function LibraryMenuItems({ libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; theme: UIAppState["theme"]; id: string; + selectedItems: LibraryItem["id"][]; + onSelectItems: (id: LibraryItem["id"][]) => void; }) { - const [selectedItems, setSelectedItems] = useState([]); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -49,13 +68,16 @@ export default function LibraryMenuItems({ libraryContainerRef.current?.scrollTo(0, scrollPosition); } }, []); // eslint-disable-line react-hooks/exhaustive-deps - const { svgCache } = useLibraryCache(); - const unpublishedItems = libraryItems.filter( - (item) => item.status !== "published", + const { svgCache } = useLibraryCache(); + const unpublishedItems = useMemo( + () => libraryItems.filter((item) => item.status !== "published"), + [libraryItems], ); - const publishedItems = libraryItems.filter( - (item) => item.status === "published", + + const publishedItems = useMemo( + () => libraryItems.filter((item) => item.status === "published"), + [libraryItems], ); const showBtn = !libraryItems.length && !pendingElements.length; @@ -69,50 +91,56 @@ export default function LibraryMenuItems({ LibraryItem["id"] | null >(null); - const onItemSelectToggle = ( - id: LibraryItem["id"], - event: React.MouseEvent, - ) => { - const shouldSelect = !selectedItems.includes(id); + const onItemSelectToggle = useCallback( + (id: LibraryItem["id"], event: React.MouseEvent) => { + const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; + const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { - if (event.shiftKey && lastSelectedItem) { - const rangeStart = orderedItems.findIndex( - (item) => item.id === lastSelectedItem, - ); - const rangeEnd = orderedItems.findIndex((item) => item.id === id); + if (shouldSelect) { + if (event.shiftKey && lastSelectedItem) { + const rangeStart = orderedItems.findIndex( + (item) => item.id === lastSelectedItem, + ); + const rangeEnd = orderedItems.findIndex((item) => item.id === id); - if (rangeStart === -1 || rangeEnd === -1) { - setSelectedItems([...selectedItems, id]); - return; + if (rangeStart === -1 || rangeEnd === -1) { + onSelectItems([...selectedItems, id]); + return; + } + + const selectedItemsMap = arrayToMap(selectedItems); + const nextSelectedIds = orderedItems.reduce( + (acc: LibraryItem["id"][], item, idx) => { + if ( + (idx >= rangeStart && idx <= rangeEnd) || + selectedItemsMap.has(item.id) + ) { + acc.push(item.id); + } + return acc; + }, + [], + ); + + onSelectItems(nextSelectedIds); + } else { + onSelectItems([...selectedItems, id]); } - - const selectedItemsMap = arrayToMap(selectedItems); - const nextSelectedIds = orderedItems.reduce( - (acc: LibraryItem["id"][], item, idx) => { - if ( - (idx >= rangeStart && idx <= rangeEnd) || - selectedItemsMap.has(item.id) - ) { - acc.push(item.id); - } - return acc; - }, - [], - ); - - setSelectedItems(nextSelectedIds); + setLastSelectedItem(id); } else { - setSelectedItems([...selectedItems, id]); + setLastSelectedItem(null); + onSelectItems(selectedItems.filter((_id) => _id !== id)); } - setLastSelectedItem(id); - } else { - setLastSelectedItem(null); - setSelectedItems(selectedItems.filter((_id) => _id !== id)); - } - }; + }, + [ + lastSelectedItem, + onSelectItems, + publishedItems, + selectedItems, + unpublishedItems, + ], + ); const getInsertedElements = useCallback( (id: string) => { @@ -136,37 +164,45 @@ export default function LibraryMenuItems({ [libraryItems, selectedItems], ); - const onItemDrag = (id: LibraryItem["id"], event: React.DragEvent) => { - event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), - ); - }; + const onItemDrag = useCallback( + (id: LibraryItem["id"], event: React.DragEvent) => { + event.dataTransfer.setData( + MIME_TYPES.excalidrawlib, + serializeLibraryAsJSON(getInsertedElements(id)), + ); + }, + [getInsertedElements], + ); - const isItemSelected = (id: LibraryItem["id"] | null) => { - if (!id) { - return false; - } + const isItemSelected = useCallback( + (id: LibraryItem["id"] | null) => { + if (!id) { + return false; + } - return selectedItems.includes(id); - }; + return selectedItems.includes(id); + }, + [selectedItems], + ); + + const onAddToLibraryClick = useCallback(() => { + onAddToLibrary(pendingElements); + }, [pendingElements, onAddToLibrary]); const onItemClick = useCallback( (id: LibraryItem["id"] | null) => { - if (!id) { - onAddToLibrary(pendingElements); - } else { + if (id) { onInsertLibraryItems(getInsertedElements(id)); } }, - [ - getInsertedElements, - onAddToLibrary, - onInsertLibraryItems, - pendingElements, - ], + [getInsertedElements, onInsertLibraryItems], ); + const itemsRenderedPerBatch = + svgCache.size >= libraryItems.length + ? CACHED_ITEMS_RENDERED_PER_BATCH + : ITEMS_RENDERED_PER_BATCH; + return (
)} @@ -225,20 +261,28 @@ export default function LibraryMenuItems({
) : ( - + + {pendingElements.length > 0 && ( + + )} + + )} @@ -251,14 +295,17 @@ export default function LibraryMenuItems({
)} {publishedItems.length > 0 ? ( - + + + ) : unpublishedItems.length > 0 ? (
)} diff --git a/src/components/LibraryMenuSection.tsx b/src/components/LibraryMenuSection.tsx index 496f045b71..0e10470fc9 100644 --- a/src/components/LibraryMenuSection.tsx +++ b/src/components/LibraryMenuSection.tsx @@ -1,16 +1,10 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { LibraryUnit } from "./LibraryUnit"; +import React, { memo, ReactNode, useEffect, useState } from "react"; +import { EmptyLibraryUnit, LibraryUnit } from "./LibraryUnit"; import { LibraryItem } from "../types"; -import Stack from "./Stack"; -import clsx from "clsx"; import { ExcalidrawElement, NonDeleted } from "../element/types"; import { SvgCache } from "../hooks/useLibraryItemSvg"; import { useTransition } from "../hooks/useTransition"; -const ITEMS_PER_ROW = 4; -const ROWS_RENDERED_PER_BATCH = 6; -const CACHED_ROWS_RENDERED_PER_BATCH = 16; - type LibraryOrPendingItem = ( | LibraryItem | /* pending library item */ { @@ -26,91 +20,58 @@ interface Props { onItemDrag: (id: LibraryItem["id"], event: React.DragEvent) => void; isItemSelected: (id: LibraryItem["id"] | null) => boolean; svgCache: SvgCache; + itemsRenderedPerBatch: number; } -function LibraryRow({ - items, - onItemSelectToggle, - onItemDrag, - isItemSelected, - onClick, - svgCache, -}: Props) { - return ( - - {items.map((item) => ( - - - - ))} - - ); -} +export const LibraryMenuSectionGrid = ({ + children, +}: { + children: ReactNode; +}) => { + return
{children}
; +}; -const EmptyLibraryRow = () => ( - - {Array.from({ length: ITEMS_PER_ROW }).map((_, index) => ( - -
- - ))} - +export const LibraryMenuSection = memo( + ({ + items, + onItemSelectToggle, + onItemDrag, + isItemSelected, + onClick, + svgCache, + itemsRenderedPerBatch, + }: Props) => { + const [, startTransition] = useTransition(); + const [index, setIndex] = useState(0); + + useEffect(() => { + if (index < items.length) { + startTransition(() => { + setIndex(index + itemsRenderedPerBatch); + }); + } + }, [index, items.length, startTransition, itemsRenderedPerBatch]); + + return ( + <> + {items.map((item, i) => { + return i < index ? ( + + ) : ( + + ); + })} + + ); + }, ); - -function LibraryMenuSection({ - items, - onItemSelectToggle, - onItemDrag, - isItemSelected, - onClick, - svgCache, -}: Props) { - const rows = Math.ceil(items.length / ITEMS_PER_ROW); - const [, startTransition] = useTransition(); - const [index, setIndex] = useState(0); - - const rowsRenderedPerBatch = useMemo(() => { - return svgCache.size === 0 - ? ROWS_RENDERED_PER_BATCH - : CACHED_ROWS_RENDERED_PER_BATCH; - }, [svgCache]); - - useEffect(() => { - if (index < rows) { - startTransition(() => { - setIndex(index + rowsRenderedPerBatch); - }); - } - }, [index, rows, startTransition, rowsRenderedPerBatch]); - - return ( - <> - {Array.from({ length: rows }).map((_, i) => - i < index ? ( - - ) : ( - - ), - )} - - ); -} - -export default LibraryMenuSection; diff --git a/src/components/LibraryUnit.scss b/src/components/LibraryUnit.scss index 1bfc1e6dfe..a5f26bff4e 100644 --- a/src/components/LibraryUnit.scss +++ b/src/components/LibraryUnit.scss @@ -30,7 +30,7 @@ var(--color-gray-10) ); background-size: 200% 200%; - animation: library-unit__skeleton-opacity-animation 0.3s linear; + animation: library-unit__skeleton-opacity-animation 0.2s linear; } } diff --git a/src/components/LibraryUnit.tsx b/src/components/LibraryUnit.tsx index f256b49f18..ce5d4ebbb0 100644 --- a/src/components/LibraryUnit.tsx +++ b/src/components/LibraryUnit.tsx @@ -1,5 +1,5 @@ import clsx from "clsx"; -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { useDevice } from "../components/App"; import { LibraryItem } from "../types"; import "./LibraryUnit.scss"; @@ -7,96 +7,101 @@ import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; import { SvgCache, useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; -export const LibraryUnit = ({ - id, - elements, - isPending, - onClick, - selected, - onToggle, - onDrag, - svgCache, -}: { - id: LibraryItem["id"] | /** for pending item */ null; - elements?: LibraryItem["elements"]; - isPending?: boolean; - onClick: (id: LibraryItem["id"] | null) => void; - selected: boolean; - onToggle: (id: string, event: React.MouseEvent) => void; - onDrag: (id: string, event: React.DragEvent) => void; - svgCache: SvgCache; -}) => { - const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); +export const LibraryUnit = memo( + ({ + id, + elements, + isPending, + onClick, + selected, + onToggle, + onDrag, + svgCache, + }: { + id: LibraryItem["id"] | /** for pending item */ null; + elements?: LibraryItem["elements"]; + isPending?: boolean; + onClick: (id: LibraryItem["id"] | null) => void; + selected: boolean; + onToggle: (id: string, event: React.MouseEvent) => void; + onDrag: (id: string, event: React.DragEvent) => void; + svgCache: SvgCache; + }) => { + const ref = useRef(null); + const svg = useLibraryItemSvg(id, elements, svgCache); - useEffect(() => { - const node = ref.current; + useEffect(() => { + const node = ref.current; - if (!node) { - return; - } + if (!node) { + return; + } - if (svg) { - svg.querySelector(".style-fonts")?.remove(); - node.innerHTML = svg.outerHTML; - } + if (svg) { + node.innerHTML = svg.outerHTML; + } - return () => { - node.innerHTML = ""; - }; - }, [elements, svg]); + return () => { + node.innerHTML = ""; + }; + }, [svg]); - const [isHovered, setIsHovered] = useState(false); - const isMobile = useDevice().isMobile; - const adder = isPending && ( -
{PlusIcon}
- ); + const [isHovered, setIsHovered] = useState(false); + const isMobile = useDevice().isMobile; + const adder = isPending && ( +
{PlusIcon}
+ ); - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > + return (
{ - if (id && event.shiftKey) { - onToggle(id, event); - } else { - onClick(id); + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
{ + if (id && event.shiftKey) { + onToggle(id, event); + } else { + onClick(id); + } } - } - : undefined - } - onDragStart={(event) => { - if (!id) { - event.preventDefault(); - return; + : undefined } - setIsHovered(false); - onDrag(id, event); - }} - /> - {adder} - {id && elements && (isHovered || isMobile || selected) && ( - onToggle(id, event)} - className="library-unit__checkbox" + onDragStart={(event) => { + if (!id) { + event.preventDefault(); + return; + } + setIsHovered(false); + onDrag(id, event); + }} /> - )} -
- ); -}; + {adder} + {id && elements && (isHovered || isMobile || selected) && ( + onToggle(id, event)} + className="library-unit__checkbox" + /> + )} +
+ ); + }, +); + +export const EmptyLibraryUnit = () => ( +
+); diff --git a/src/hooks/useLibraryItemSvg.ts b/src/hooks/useLibraryItemSvg.ts index ba98021999..1c27f0ce7a 100644 --- a/src/hooks/useLibraryItemSvg.ts +++ b/src/hooks/useLibraryItemSvg.ts @@ -39,6 +39,7 @@ export const useLibraryItemSvg = ( // When there is no svg in cache export it and save to cache (async () => { const exportedSvg = await exportLibraryItemToSvg(elements); + exportedSvg.querySelector(".style-fonts")?.remove(); if (exportedSvg) { svgCache.set(id, exportedSvg); From 7bf4de5892a1ef7899b0f701ded79ad835b7a1b2 Mon Sep 17 00:00:00 2001 From: Are Date: Wed, 31 May 2023 18:27:29 +0200 Subject: [PATCH 12/16] feat: redesign of Live Collaboration dialog (#6635) * feat: redesiged Live Collaboration dialog * fix: address lints * fix: inactive dialog dark mode improvements * fix: follow styleguide with event parameter, add FilledButton size prop * fix: change timer to be imperative * fix: add spacing after emoji * fix: remove unused useEffect * fix: change margin into whitespace * fix: add share button check back --- src/assets/lock.svg | 20 ++ src/components/FilledButton.scss | 95 +++++++ src/components/FilledButton.tsx | 61 +++++ src/components/ImageExportDialog.scss | 42 ---- src/components/ImageExportDialog.tsx | 38 +-- src/components/Switch.tsx | 4 +- src/components/TextField.scss | 118 +++++++++ src/components/TextField.tsx | 57 +++++ src/components/icons.tsx | 28 +++ src/css/theme.scss | 2 + src/excalidraw-app/collab/RoomDialog.scss | 191 +++++++++----- src/excalidraw-app/collab/RoomDialog.tsx | 293 ++++++++++++---------- src/excalidraw-app/index.tsx | 2 +- 13 files changed, 698 insertions(+), 253 deletions(-) create mode 100644 src/assets/lock.svg create mode 100644 src/components/FilledButton.scss create mode 100644 src/components/FilledButton.tsx create mode 100644 src/components/TextField.scss create mode 100644 src/components/TextField.tsx diff --git a/src/assets/lock.svg b/src/assets/lock.svg new file mode 100644 index 0000000000..aa9dbf1701 --- /dev/null +++ b/src/assets/lock.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/FilledButton.scss b/src/components/FilledButton.scss new file mode 100644 index 0000000000..d742e22e73 --- /dev/null +++ b/src/components/FilledButton.scss @@ -0,0 +1,95 @@ +@import "../css/variables.module"; + +.excalidraw { + .ExcButton { + &--color-primary { + color: var(--input-bg-color); + + --accent-color: var(--color-primary); + --accent-color-hover: var(--color-primary-darker); + --accent-color-active: var(--color-primary-darkest); + } + + &--color-danger { + color: var(--input-bg-color); + + --accent-color: var(--color-danger); + --accent-color-hover: #d65550; + --accent-color-active: #d1413c; + } + + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + flex-wrap: nowrap; + + border-radius: 0.5rem; + + font-family: "Assistant"; + + user-select: none; + + transition: all 150ms ease-out; + + &--size-large { + font-weight: 400; + font-size: 0.875rem; + height: 3rem; + padding: 0.5rem 1.5rem; + gap: 0.75rem; + + letter-spacing: 0.4px; + } + + &--size-medium { + font-weight: 600; + font-size: 0.75rem; + height: 2.5rem; + padding: 0.5rem 1rem; + gap: 0.5rem; + + letter-spacing: normal; + } + + &--variant-filled { + background: var(--accent-color); + border: 1px solid transparent; + + &:hover { + background: var(--accent-color-hover); + } + + &:active { + background: var(--accent-color-active); + } + } + + &--variant-outlined, + &--variant-icon { + border: 1px solid var(--accent-color); + color: var(--accent-color); + background: transparent; + + &:hover { + border: 1px solid var(--accent-color-hover); + color: var(--accent-color-hover); + } + + &:active { + border: 1px solid var(--accent-color-active); + color: var(--accent-color-active); + } + } + + &--variant-icon { + padding: 0.5rem 0.75rem; + width: 3rem; + } + + &__icon { + width: 1.25rem; + height: 1.25rem; + } + } +} diff --git a/src/components/FilledButton.tsx b/src/components/FilledButton.tsx new file mode 100644 index 0000000000..0db7242169 --- /dev/null +++ b/src/components/FilledButton.tsx @@ -0,0 +1,61 @@ +import React, { forwardRef } from "react"; +import clsx from "clsx"; + +import "./FilledButton.scss"; + +export type ButtonVariant = "filled" | "outlined" | "icon"; +export type ButtonColor = "primary" | "danger"; +export type ButtonSize = "medium" | "large"; + +export type FilledButtonProps = { + label: string; + + children?: React.ReactNode; + onClick?: () => void; + + variant?: ButtonVariant; + color?: ButtonColor; + size?: ButtonSize; + className?: string; + + startIcon?: React.ReactNode; +}; + +export const FilledButton = forwardRef( + ( + { + children, + startIcon, + onClick, + label, + variant = "filled", + color = "primary", + size = "medium", + className, + }, + ref, + ) => { + return ( + + ); + }, +); diff --git a/src/components/ImageExportDialog.scss b/src/components/ImageExportDialog.scss index a74cfdc20e..093e1a76ff 100644 --- a/src/components/ImageExportDialog.scss +++ b/src/components/ImageExportDialog.scss @@ -167,48 +167,6 @@ flex-basis: 100%; justify-content: center; } - - &__button { - box-sizing: border-box; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - padding: 8px 16px; - flex-shrink: 0; - width: fit-content; - gap: 8px; - - height: 40px; - border: 0; - border-radius: 8px; - - user-select: none; - font-family: "Assistant"; - font-style: normal; - font-weight: 600; - font-size: 0.75rem; - line-height: 100%; - transition: 150ms ease-out; - transition-property: background, color; - - background: var(--color-primary); - color: var(--color-icon-white); - - &:hover { - background: var(--color-primary-darker); - color: var(--color-icon-white); - } - - &:active { - background: var(--color-primary-darkest); - } - - & > svg { - width: 20px; - height: 20px; - } - } } } } diff --git a/src/components/ImageExportDialog.tsx b/src/components/ImageExportDialog.tsx index 604f50b4f3..042d5a3fb8 100644 --- a/src/components/ImageExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -26,7 +26,6 @@ import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas } from "../packages/utils"; import { copyIcon, downloadIcon, helpIcon } from "./icons"; -import { Button } from "./Button"; import { Dialog } from "./Dialog"; import { RadioGroup } from "./RadioGroup"; import { Switch } from "./Switch"; @@ -34,6 +33,7 @@ import { Tooltip } from "./Tooltip"; import "./ImageExportDialog.scss"; import { useAppProps } from "./App"; +import { FilledButton } from "./FilledButton"; const supportsContextFilters = "filter" in document.createElement("canvas").getContext("2d")!; @@ -236,37 +236,37 @@ const ImageExportModal = ({
- - + {t("imageExportDialog.button.exportToSvg")} + {(probablySupportsClipboardBlob || isFirefox) && ( - + {t("imageExportDialog.button.copyPngToClipboard")} + )}
diff --git a/src/components/Switch.tsx b/src/components/Switch.tsx index dfbf332fce..431c644f04 100644 --- a/src/components/Switch.tsx +++ b/src/components/Switch.tsx @@ -27,8 +27,8 @@ export const Switch = ({ checked={checked} disabled={disabled} onChange={() => onChange(!checked)} - onKeyDown={(e) => { - if (e.key === " ") { + onKeyDown={(event) => { + if (event.key === " ") { onChange(!checked); } }} diff --git a/src/components/TextField.scss b/src/components/TextField.scss new file mode 100644 index 0000000000..57093996ca --- /dev/null +++ b/src/components/TextField.scss @@ -0,0 +1,118 @@ +@import "../css/variables.module"; + +.excalidraw { + --ExcTextField--color: var(--color-gray-80); + --ExcTextField--label-color: var(--color-gray-80); + --ExcTextField--background: white; + --ExcTextField--readonly--background: var(--color-gray-10); + --ExcTextField--readonly--color: var(--color-gray-80); + --ExcTextField--border: var(--color-gray-40); + --ExcTextField--border-hover: var(--color-gray-50); + --ExcTextField--placeholder: var(--color-gray-40); + + &.theme--dark { + --ExcTextField--color: var(--color-gray-10); + --ExcTextField--label-color: var(--color-gray-20); + --ExcTextField--background: var(--color-gray-85); + --ExcTextField--readonly--background: var(--color-gray-80); + --ExcTextField--readonly--color: var(--color-gray-40); + --ExcTextField--border: var(--color-gray-70); + --ExcTextField--border-hover: var(--color-gray-60); + --ExcTextField--placeholder: var(--color-gray-80); + } + + .ExcTextField { + &--fullWidth { + width: 100%; + flex-grow: 1; + } + + &__label { + font-family: "Assistant"; + font-style: normal; + font-weight: 600; + font-size: 0.875rem; + line-height: 150%; + + color: var(--ExcTextField--label-color); + + margin-bottom: 0.25rem; + user-select: none; + } + + &__input { + box-sizing: border-box; + + display: flex; + flex-direction: row; + align-items: center; + padding: 0 1rem; + + height: 3rem; + + background: var(--ExcTextField--background); + border: 1px solid var(--ExcTextField--border); + border-radius: 0.5rem; + + &:not(&--readonly) { + &:hover { + border-color: var(--ExcTextField--border-hover); + } + + &:active, + &:focus-within { + border-color: var(--color-primary); + } + } + + & input { + display: flex; + align-items: center; + + border: none; + outline: none; + padding: 0; + margin: 0; + + height: 1.5rem; + + color: var(--ExcTextField--color); + + font-family: "Assistant"; + font-style: normal; + font-weight: 400; + font-size: 1rem; + line-height: 150%; + text-overflow: ellipsis; + + background: transparent; + + width: 100%; + + &::placeholder { + color: var(--ExcTextField--placeholder); + } + + &:not(:focus) { + &:hover { + background-color: initial; + } + } + + &:focus { + outline: initial; + box-shadow: initial; + } + } + + &--readonly { + background: var(--ExcTextField--readonly--background); + border-color: transparent; + + & input { + color: var(--ExcTextField--readonly--color); + } + } + } + } +} diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx new file mode 100644 index 0000000000..7f7a41fd09 --- /dev/null +++ b/src/components/TextField.tsx @@ -0,0 +1,57 @@ +import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react"; +import clsx from "clsx"; + +import "./TextField.scss"; + +export type TextFieldProps = { + value?: string; + + onChange?: (value: string) => void; + onClick?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + + readonly?: boolean; + fullWidth?: boolean; + + label?: string; + placeholder?: string; +}; + +export const TextField = forwardRef( + ( + { value, onChange, label, fullWidth, placeholder, readonly, onKeyDown }, + ref, + ) => { + const innerRef = useRef(null); + + useImperativeHandle(ref, () => innerRef.current!); + + return ( +
{ + innerRef.current?.focus(); + }} + > +
{label}
+
+ onChange?.(event.target.value)} + onKeyDown={onKeyDown} + /> +
+
+ ); + }, +); diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 3841d030e6..543248a1ad 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -1579,3 +1579,31 @@ export const helpIcon = createIcon( , tablerIconProps, ); + +export const playerPlayIcon = createIcon( + <> + + + , + tablerIconProps, +); + +export const playerStopFilledIcon = createIcon( + <> + + + , + tablerIconProps, +); + +export const tablerCheckIcon = createIcon( + <> + + + , + tablerIconProps, +); diff --git a/src/css/theme.scss b/src/css/theme.scss index c8abc4fff3..92b5989a8e 100644 --- a/src/css/theme.scss +++ b/src/css/theme.scss @@ -103,6 +103,8 @@ --color-danger: #db6965; --color-promo: #e70078; + --color-success: #268029; + --color-success-lighter: #cafccc; --border-radius-md: 0.375rem; --border-radius-lg: 0.5rem; diff --git a/src/excalidraw-app/collab/RoomDialog.scss b/src/excalidraw-app/collab/RoomDialog.scss index c8bf0dcdec..0d1bcad6cc 100644 --- a/src/excalidraw-app/collab/RoomDialog.scss +++ b/src/excalidraw-app/collab/RoomDialog.scss @@ -1,76 +1,149 @@ @import "../../css/variables.module"; .excalidraw { - .RoomDialog__button { - border: 1px solid var(--default-border-color) !important; - } - - .RoomDialog-linkContainer { + .RoomDialog { display: flex; - margin: 1.5em 0; - } + flex-direction: column; + gap: 1.5rem; - input.RoomDialog-link { - color: var(--text-primary-color); - min-width: 0; - flex: 1 1 auto; - margin-inline-start: 1em; - display: inline-block; - cursor: pointer; - border: none; - padding: 0 0.5rem; - white-space: nowrap; - border-radius: var(--space-factor); - background-color: var(--button-gray-1); - } - - .RoomDialog-emoji { - font-family: sans-serif; - } - - .RoomDialog-usernameContainer { - display: flex; - margin: 1.5em 0; - display: flex; - align-items: center; - justify-content: center; @include isMobile { - flex-direction: column; - align-items: stretch; + height: calc(100vh - 5rem); } - } - @include isMobile { - .RoomDialog-usernameLabel { - font-weight: bold; + &__popover { + @keyframes RoomDialog__popover__scaleIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + box-sizing: border-box; + z-index: 100; + + display: flex; + flex-direction: row; + justify-content: center; + align-items: flex-start; + padding: 0.125rem 0.5rem; + gap: 0.125rem; + + height: 1.125rem; + + border: none; + border-radius: 0.6875rem; + + font-family: "Assistant"; + font-style: normal; + font-weight: 600; + font-size: 0.75rem; + line-height: 110%; + + background: var(--color-success-lighter); + color: var(--color-success); + + & > svg { + width: 0.875rem; + height: 0.875rem; + } + + transform-origin: var(--radix-popover-content-transform-origin); + animation: RoomDialog__popover__scaleIn 150ms ease-out; } - } - .RoomDialog-username { - background-color: var(--input-bg-color); - border-color: var(--input-border-color); - appearance: none; - min-width: 0; - flex: 1 1 auto; - margin-inline-start: 1em; - @include isMobile { - margin-top: 0.5em; - margin-inline-start: 0; + &__inactive { + font-family: "Assistant"; + + &__illustration { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + + & svg { + filter: var(--theme-filter); + } + } + + &__header { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + + font-weight: 700; + font-size: 1.3125rem; + line-height: 130%; + + color: var(--color-primary); + } + + &__description { + font-weight: 400; + font-size: 0.875rem; + line-height: 150%; + + text-align: center; + + color: var(--text-primary-color); + + & strong { + display: block; + font-weight: 700; + } + } + + &__start_session { + display: flex; + + align-items: center; + justify-content: center; + } } - font-size: 1em; - } - .RoomDialog-sessionStartButtonContainer { - display: flex; - justify-content: center; - } + &__active { + &__share { + display: none !important; - .Modal .RoomDialog-stopSession { - background-color: var(--button-destructive-bg-color); + @include isMobile { + display: flex !important; + } + } - .ToolIcon__label, - .ToolIcon__icon svg { - color: var(--button-destructive-color); + &__header { + margin: 0; + } + + &__linkRow { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 0.75rem; + } + + &__description { + border-top: 1px solid var(--color-gray-20); + + padding: 0.5rem 0.5rem 0; + font-weight: 400; + font-size: 0.75rem; + line-height: 150%; + + & p { + margin: 0; + } + + & p + p { + margin-top: 1em; + } + } + + &__actions { + display: flex; + justify-content: center; + } } } } diff --git a/src/excalidraw-app/collab/RoomDialog.tsx b/src/excalidraw-app/collab/RoomDialog.tsx index 05772774ad..daec8cfe94 100644 --- a/src/excalidraw-app/collab/RoomDialog.tsx +++ b/src/excalidraw-app/collab/RoomDialog.tsx @@ -1,24 +1,29 @@ -import React, { useRef } from "react"; +import { useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; + import { copyTextToSystemClipboard } from "../../clipboard"; -import { Dialog } from "../../components/Dialog"; -import { - clipboard, - start, - stop, - share, - shareIOS, - shareWindows, -} from "../../components/icons"; -import { ToolButton } from "../../components/ToolButton"; -import "./RoomDialog.scss"; -import Stack from "../../components/Stack"; import { AppState } from "../../types"; import { trackEvent } from "../../analytics"; import { getFrame } from "../../utils"; -import DialogActionButton from "../../components/DialogActionButton"; import { useI18n } from "../../i18n"; import { KEYS } from "../../keys"; +import { Dialog } from "../../components/Dialog"; +import { + copyIcon, + playerPlayIcon, + playerStopFilledIcon, + share, + shareIOS, + shareWindows, + tablerCheckIcon, +} from "../../components/icons"; +import { TextField } from "../../components/TextField"; +import { FilledButton } from "../../components/FilledButton"; + +import { ReactComponent as CollabImage } from "../../assets/lock.svg"; +import "./RoomDialog.scss"; + const getShareIcon = () => { const navigator = window.navigator as any; const isAppleBrowser = /Apple/.test(navigator.vendor); @@ -33,16 +38,7 @@ const getShareIcon = () => { return share; }; -const RoomDialog = ({ - handleClose, - activeRoomLink, - username, - onUsernameChange, - onRoomCreate, - onRoomDestroy, - setErrorMessage, - theme, -}: { +export type RoomModalProps = { handleClose: () => void; activeRoomLink: string; username: string; @@ -51,19 +47,41 @@ const RoomDialog = ({ onRoomDestroy: () => void; setErrorMessage: (message: string) => void; theme: AppState["theme"]; -}) => { +}; + +export const RoomModal = ({ + activeRoomLink, + onRoomCreate, + onRoomDestroy, + setErrorMessage, + username, + onUsernameChange, + handleClose, +}: RoomModalProps) => { const { t } = useI18n(); - const roomLinkInput = useRef(null); + const [justCopied, setJustCopied] = useState(false); + const timerRef = useRef(0); + const ref = useRef(null); + const isShareSupported = "share" in navigator; const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); + + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); } catch (error: any) { setErrorMessage(error.message); } - if (roomLinkInput.current) { - roomLinkInput.current.select(); - } + + ref.current?.select(); }; const shareRoomLink = async () => { @@ -78,114 +96,129 @@ const RoomDialog = ({ } }; - const selectInput = (event: React.MouseEvent) => { - if (event.target !== document.activeElement) { - event.preventDefault(); - (event.target as HTMLInputElement).select(); - } - }; - - const renderRoomDialog = () => { + if (activeRoomLink) { return ( -
- {!activeRoomLink && ( - <> -

{t("roomDialog.desc_intro")}

-

{`🔒 ${t("roomDialog.desc_privacy")}`}

-
- { - trackEvent("share", "room creation", `ui (${getFrame()})`); - onRoomCreate(); - }} - > - {start} - -
- - )} - {activeRoomLink && ( - <> -

{t("roomDialog.desc_inProgressIntro")}

-

{t("roomDialog.desc_shareLink")}

-
- - {"share" in navigator ? ( - - ) : null} - - - +

+ {t("labels.liveCollaboration")} +

+ event.key === KEYS.ENTER && handleClose()} + /> +
+ + {isShareSupported && ( + + )} + + + -
-
- - onUsernameChange(event.target.value)} - onKeyPress={(event) => - event.key === KEYS.ENTER && handleClose() - } - /> -
-

- - {t("roomDialog.desc_privacy")} -

-

{t("roomDialog.desc_exitSession")}

-
- { - trackEvent("share", "room closed"); - onRoomDestroy(); - }} - > - {stop} - -
- - )} -
+ + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + className="RoomDialog__popover" + side="top" + align="end" + sideOffset={5.5} + > + {tablerCheckIcon} copied + + +
+
+

+ + {t("roomDialog.desc_privacy")} +

+

{t("roomDialog.desc_exitSession")}

+
+ +
+ { + trackEvent("share", "room closed"); + onRoomDestroy(); + }} + /> +
+ ); - }; + } + + return ( + <> +
+ +
+
+ {t("labels.liveCollaboration")} +
+ +
+ {t("roomDialog.desc_intro")} + {t("roomDialog.desc_privacy")} +
+ +
+ { + trackEvent("share", "room creation", `ui (${getFrame()})`); + onRoomCreate(); + }} + /> +
+ + ); +}; + +const RoomDialog = (props: RoomModalProps) => { return ( - {renderRoomDialog()} +
+ +
); }; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 9017bbb87b..e1af548070 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -671,8 +671,8 @@ const ExcalidrawWrapper = () => { {t("alerts.collabOfflineWarning")}
)} + {excalidrawAPI && } - {excalidrawAPI && } {errorMessage && ( setErrorMessage("")}> {errorMessage} From 644685a5a8a016b813b3add36de026f5ead5c4c9 Mon Sep 17 00:00:00 2001 From: Sudharsan Aravind <63002244+vsaravind01@users.noreply.github.com> Date: Fri, 2 Jun 2023 02:47:22 +0530 Subject: [PATCH 13/16] fix: color picker input closing problem (#6599) --- src/components/ColorPicker/ColorPicker.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ColorPicker/ColorPicker.tsx b/src/components/ColorPicker/ColorPicker.tsx index 25ac7c1a85..c470cc0884 100644 --- a/src/components/ColorPicker/ColorPicker.tsx +++ b/src/components/ColorPicker/ColorPicker.tsx @@ -66,7 +66,6 @@ const ColorPickerPopupContent = ({ | "color" | "onChange" | "label" - | "label" | "elements" | "palette" | "updateData" @@ -100,6 +99,8 @@ const ColorPickerPopupContent = ({ container.focus(); } + updateData({ openPopup: null }); + e.preventDefault(); e.stopPropagation(); From 079aa72475f60dfeb4221ade4e9e4e6e1edfbb59 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Fri, 2 Jun 2023 17:06:11 +0200 Subject: [PATCH 14/16] feat: eye dropper (#6615) --- package.json | 2 +- src/actions/actionProperties.tsx | 32 +-- src/colors.ts | 3 + src/components/App.tsx | 94 ++++++-- src/components/ColorPicker/ColorInput.tsx | 71 +++++- src/components/ColorPicker/ColorPicker.scss | 1 + src/components/ColorPicker/ColorPicker.tsx | 76 ++++++- .../ColorPicker/CustomColorList.tsx | 2 +- src/components/ColorPicker/Picker.tsx | 50 ++-- .../ColorPicker/PickerColorList.tsx | 8 +- src/components/ColorPicker/ShadeList.tsx | 8 +- src/components/ColorPicker/TopPicks.tsx | 2 +- .../ColorPicker/colorPickerUtils.ts | 15 +- .../ColorPicker/keyboardNavHandlers.ts | 102 ++++++--- src/components/Dialog.tsx | 3 - src/components/EyeDropper.scss | 48 ++++ src/components/EyeDropper.tsx | 215 ++++++++++++++++++ src/components/HelpDialog.tsx | 4 + src/components/LayerUI.tsx | 25 +- src/components/Modal.tsx | 51 +---- src/components/Sidebar/Sidebar.tsx | 35 +-- .../dropdownMenu/DropdownMenuContent.tsx | 9 +- src/components/icons.tsx | 9 + src/constants.ts | 1 + src/excalidraw-app/collab/Collab.tsx | 1 - src/excalidraw-app/collab/RoomDialog.tsx | 9 +- src/hooks/useCreatePortalContainer.ts | 49 ++++ src/hooks/useOutsideClick.ts | 116 +++++++--- src/locales/en.json | 3 +- src/types.ts | 1 + yarn.lock | 8 +- 31 files changed, 803 insertions(+), 250 deletions(-) create mode 100644 src/components/EyeDropper.scss create mode 100644 src/components/EyeDropper.tsx create mode 100644 src/hooks/useCreatePortalContainer.ts diff --git a/package.json b/package.json index 246c785a52..91a4400b6e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "i18next-browser-languagedetector": "6.1.4", "idb-keyval": "6.0.3", "image-blob-reduce": "3.0.1", - "jotai": "1.6.4", + "jotai": "1.13.1", "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "open-color": "1.9.1", diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 6e921d6ea8..d319337c3f 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -119,8 +119,8 @@ const getFormValue = function ( elements: readonly ExcalidrawElement[], appState: AppState, getAttribute: (element: ExcalidrawElement) => T, - defaultValue?: T, -): T | null { + defaultValue: T, +): T { const editingElement = appState.editingElement; const nonDeletedElements = getNonDeletedElements(elements); return ( @@ -132,7 +132,7 @@ const getFormValue = function ( getAttribute, ) : defaultValue) ?? - null + defaultValue ); }; @@ -811,6 +811,7 @@ export const actionChangeTextAlign = register({ ); }, }); + export const actionChangeVerticalAlign = register({ name: "changeVerticalAlign", trackEvent: { category: "element" }, @@ -865,16 +866,21 @@ export const actionChangeVerticalAlign = register({ testId: "align-bottom", }, ]} - value={getFormValue(elements, appState, (element) => { - if (isTextElement(element) && element.containerId) { - return element.verticalAlign; - } - const boundTextElement = getBoundTextElement(element); - if (boundTextElement) { - return boundTextElement.verticalAlign; - } - return null; - })} + value={getFormValue( + elements, + appState, + (element) => { + if (isTextElement(element) && element.containerId) { + return element.verticalAlign; + } + const boundTextElement = getBoundTextElement(element); + if (boundTextElement) { + return boundTextElement.verticalAlign; + } + return null; + }, + VERTICAL_ALIGN.MIDDLE, + )} onChange={(value) => updateData(value)} /> diff --git a/src/colors.ts b/src/colors.ts index 198ec12e23..7da128399c 100644 --- a/src/colors.ts +++ b/src/colors.ts @@ -164,4 +164,7 @@ export const getAllColorsSpecificShade = (index: 0 | 1 | 2 | 3 | 4) => COLOR_PALETTE.red[index], ] as const; +export const rgbToHex = (r: number, g: number, b: number) => + `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; + // ----------------------------------------------------------------------------- diff --git a/src/components/App.tsx b/src/components/App.tsx index 0b730fc47a..f4c7689bb5 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -304,6 +304,7 @@ import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; import BraveMeasureTextError from "./BraveMeasureTextError"; +import { activeEyeDropperAtom } from "./EyeDropper"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -366,8 +367,6 @@ export const useExcalidrawActionManager = () => let didTapTwice: boolean = false; let tappedTwiceTimer = 0; -let cursorX = 0; -let cursorY = 0; let isHoldingSpace: boolean = false; let isPanning: boolean = false; let isDraggingScrollBar: boolean = false; @@ -425,7 +424,7 @@ class App extends React.Component { hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | PointerEvent | null = null; - lastScenePointer: { x: number; y: number } | null = null; + lastViewportPosition = { x: 0, y: 0 }; constructor(props: AppProps) { super(props); @@ -634,6 +633,7 @@ class App extends React.Component {
+
{selectedElement.length === 1 && !this.state.contextMenu && this.state.showHyperlinkPopup && ( @@ -724,6 +724,49 @@ class App extends React.Component { } }; + private openEyeDropper = ({ type }: { type: "stroke" | "background" }) => { + jotaiStore.set(activeEyeDropperAtom, { + swapPreviewOnAlt: true, + previewType: type === "stroke" ? "strokeColor" : "backgroundColor", + onSelect: (color, event) => { + const shouldUpdateStrokeColor = + (type === "background" && event.altKey) || + (type === "stroke" && !event.altKey); + const selectedElements = getSelectedElements( + this.scene.getElementsIncludingDeleted(), + this.state, + ); + if ( + !selectedElements.length || + this.state.activeTool.type !== "selection" + ) { + if (shouldUpdateStrokeColor) { + this.setState({ + currentItemStrokeColor: color, + }); + } else { + this.setState({ + currentItemBackgroundColor: color, + }); + } + } else { + this.updateScene({ + elements: this.scene.getElementsIncludingDeleted().map((el) => { + if (this.state.selectedElementIds[el.id]) { + return newElementWith(el, { + [shouldUpdateStrokeColor ? "strokeColor" : "backgroundColor"]: + color, + }); + } + return el; + }), + }); + } + }, + keepOpenOnAlt: false, + }); + }; + private syncActionResult = withBatchedUpdates( (actionResult: ActionResult) => { if (this.unmounted || actionResult === false) { @@ -1569,7 +1612,10 @@ class App extends React.Component { return; } - const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); + const elementUnderCursor = document.elementFromPoint( + this.lastViewportPosition.x, + this.lastViewportPosition.y, + ); if ( event && (!(elementUnderCursor instanceof HTMLCanvasElement) || @@ -1597,7 +1643,10 @@ class App extends React.Component { // prefer spreadsheet data over image file (MS Office/Libre Office) if (isSupportedImageFile(file) && !data.spreadsheet) { const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, this.state, ); @@ -1660,13 +1709,13 @@ class App extends React.Component { typeof opts.position === "object" ? opts.position.clientX : opts.position === "cursor" - ? cursorX + ? this.lastViewportPosition.x : this.state.width / 2 + this.state.offsetLeft; const clientY = typeof opts.position === "object" ? opts.position.clientY : opts.position === "cursor" - ? cursorY + ? this.lastViewportPosition.y : this.state.height / 2 + this.state.offsetTop; const { x, y } = viewportCoordsToSceneCoords( @@ -1750,7 +1799,10 @@ class App extends React.Component { private addTextFromPaste(text: string, isPlainPaste = false) { const { x, y } = viewportCoordsToSceneCoords( - { clientX: cursorX, clientY: cursorY }, + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, this.state, ); @@ -2083,8 +2135,8 @@ class App extends React.Component { private updateCurrentCursorPosition = withBatchedUpdates( (event: MouseEvent) => { - cursorX = event.clientX; - cursorY = event.clientY; + this.lastViewportPosition.x = event.clientX; + this.lastViewportPosition.y = event.clientY; }, ); @@ -2342,6 +2394,20 @@ class App extends React.Component { ) { jotaiStore.set(activeConfirmDialogAtom, "clearCanvas"); } + + // eye dropper + // ----------------------------------------------------------------------- + const lowerCased = event.key.toLocaleLowerCase(); + const isPickingStroke = lowerCased === KEYS.S && event.shiftKey; + const isPickingBackground = + event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey); + + if (isPickingStroke || isPickingBackground) { + this.openEyeDropper({ + type: isPickingStroke ? "stroke" : "background", + }); + } + // ----------------------------------------------------------------------- }, ); @@ -2471,8 +2537,8 @@ class App extends React.Component { this.setState((state) => ({ ...getStateForZoom( { - viewportX: cursorX, - viewportY: cursorY, + viewportX: this.lastViewportPosition.x, + viewportY: this.lastViewportPosition.y, nextZoom: getNormalizedZoom(initialScale * event.scale), }, state, @@ -6468,8 +6534,8 @@ class App extends React.Component { this.translateCanvas((state) => ({ ...getStateForZoom( { - viewportX: cursorX, - viewportY: cursorY, + viewportX: this.lastViewportPosition.x, + viewportY: this.lastViewportPosition.y, nextZoom: getNormalizedZoom(newZoom), }, state, diff --git a/src/components/ColorPicker/ColorInput.tsx b/src/components/ColorPicker/ColorInput.tsx index bb9a85510f..f179d415c5 100644 --- a/src/components/ColorPicker/ColorInput.tsx +++ b/src/components/ColorPicker/ColorInput.tsx @@ -2,15 +2,23 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getColor } from "./ColorPicker"; import { useAtom } from "jotai"; import { activeColorPickerSectionAtom } from "./colorPickerUtils"; +import { eyeDropperIcon } from "../icons"; +import { jotaiScope } from "../../jotai"; import { KEYS } from "../../keys"; +import { activeEyeDropperAtom } from "../EyeDropper"; +import clsx from "clsx"; +import { t } from "../../i18n"; +import { useDevice } from "../App"; +import { getShortcutKey } from "../../utils"; interface ColorInputProps { - color: string | null; + color: string; onChange: (color: string) => void; label: string; } export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { + const device = useDevice(); const [innerValue, setInnerValue] = useState(color); const [activeSection, setActiveColorPickerSection] = useAtom( activeColorPickerSectionAtom, @@ -34,7 +42,7 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { ); const inputRef = useRef(null); - const divRef = useRef(null); + const eyeDropperTriggerRef = useRef(null); useEffect(() => { if (inputRef.current) { @@ -42,8 +50,19 @@ export const ColorInput = ({ color, onChange, label }: ColorInputProps) => { } }, [activeSection]); + const [eyeDropperState, setEyeDropperState] = useAtom( + activeEyeDropperAtom, + jotaiScope, + ); + + useEffect(() => { + return () => { + setEyeDropperState(null); + }; + }, [setEyeDropperState]); + return ( -