浏览代码

More mobile tweaks (#790)

* Disable text selection

* Set content-editable=plaintext-only to disable Touch Bar formatting buttons

* Enlarge resize handle tap targets for pen/touch

* Make the lock button a button in mobile mode

* Use icons instead of Unicode characters; add an alternate toolbar for creating multipoint lines

* Allow buttons to hide themselves

* Fix heuristic for showing shape actions

* Refactor icons

* Fix label for edit button

* Switch edit button icon

* Remove lock button on mobile

* Add language selector on mobile

* Fix showing edit button on mobile

* Fix showing edit button on mobile, part 2

* Fix handle touch regions

* Fix scroll-back button position

* Allow using the text tool on a text object to start editing it

* Fix deletion of last point in line
Jed Fox 5 年之前
父节点
当前提交
0fd3fb4b5b

+ 2 - 1
src/actions/actionDeleteSelected.tsx

@@ -18,13 +18,14 @@ export const actionDeleteSelected: Action = {
   contextMenuOrder: 3,
   commitToHistory: (_, elements) => isSomeElementSelected(elements),
   keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE,
-  PanelComponent: ({ updateData }) => (
+  PanelComponent: ({ elements, updateData }) => (
     <ToolButton
       type="button"
       icon={trash}
       title={t("labels.delete")}
       aria-label={t("labels.delete")}
       onClick={() => updateData(null)}
+      visible={isSomeElementSelected(elements)}
     />
   ),
 };

+ 10 - 7
src/actions/actionFinalize.tsx

@@ -5,7 +5,7 @@ import { isInvisiblySmallElement } from "../element";
 import { resetCursor } from "../utils";
 import React from "react";
 import { ToolButton } from "../components/ToolButton";
-import { save } from "../components/icons";
+import { done } from "../components/icons";
 import { t } from "../i18n";
 
 export const actionFinalize: Action = {
@@ -16,10 +16,13 @@ export const actionFinalize: Action = {
       window.document.activeElement.blur();
     }
     if (appState.multiElement) {
-      appState.multiElement.points = appState.multiElement.points.slice(
-        0,
-        appState.multiElement.points.length - 1,
-      );
+      // pen and mouse have hover
+      if (appState.lastPointerDownWith !== "touch") {
+        appState.multiElement.points = appState.multiElement.points.slice(
+          0,
+          appState.multiElement.points.length - 1,
+        );
+      }
       if (isInvisiblySmallElement(appState.multiElement)) {
         newElements = newElements.slice(0, -1);
       }
@@ -50,12 +53,12 @@ export const actionFinalize: Action = {
   PanelComponent: ({ appState, updateData }) => (
     <div
       style={{
-        visibility: appState.multiElement !== null ? "visible" : "hidden",
+        visibility: appState.multiElement != null ? "visible" : "hidden",
       }}
     >
       <ToolButton
         type="button"
-        icon={save}
+        icon={done}
         title={t("buttons.done")}
         aria-label={t("buttons.done")}
         onClick={() => updateData(null)}

+ 1 - 0
src/appState.ts

@@ -30,6 +30,7 @@ export function getDefaultAppState(): AppState {
     selectionElement: null,
     zoom: 1,
     openedMenu: null,
+    lastPointerDownWith: "mouse",
   };
 }
 

+ 5 - 1
src/components/LanguageList.tsx

@@ -5,15 +5,19 @@ export function LanguageList<T>({
   onChange,
   languages,
   currentLanguage,
+  floating,
 }: {
   languages: { lng: string; label: string }[];
   onChange: (value: string) => void;
   currentLanguage: string;
+  floating?: boolean;
 }) {
   return (
     <React.Fragment>
       <select
-        className="language-select"
+        className={`dropdown-select dropdown-select__language${
+          floating ? " dropdown-select--floating" : ""
+        }`}
         onChange={({ target }) => onChange(target.value)}
         value={currentLanguage}
         aria-label={t("buttons.selectLanguage")}

+ 7 - 1
src/components/LockIcon.tsx

@@ -11,6 +11,7 @@ type LockIconProps = {
   checked: boolean;
   onChange?(): void;
   size?: LockIconSize;
+  isButton?: boolean;
 };
 
 const DEFAULT_SIZE: LockIconSize = "m";
@@ -43,7 +44,12 @@ export function LockIcon(props: LockIconProps) {
   const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
 
   return (
-    <label className={`ToolIcon ToolIcon__lock ${sizeCn}`} title={props.title}>
+    <label
+      className={`ToolIcon ToolIcon__lock ${
+        props.isButton ? "ToolIcon_type_button" : "ToolIcon_type_floating"
+      } ${sizeCn}`}
+      title={props.title}
+    >
       <input
         className="ToolIcon_type_checkbox"
         type="checkbox"

+ 1 - 1
src/components/ProjectName.tsx

@@ -36,7 +36,7 @@ export class ProjectName extends Component<Props> {
     return (
       <span
         suppressContentEditableWarning
-        contentEditable="true"
+        contentEditable={"plaintext-only" as any}
         data-type="wysiwyg"
         className="ProjectName"
         role="textbox"

+ 5 - 0
src/components/ToolButton.tsx

@@ -15,6 +15,7 @@ type ToolButtonBaseProps = {
   size?: ToolIconSize;
   keyBindingLabel?: string;
   showAriaLabel?: boolean;
+  visible?: boolean;
 };
 
 type ToolButtonProps =
@@ -45,6 +46,10 @@ export const ToolButton = React.forwardRef(function(
         type="button"
         onClick={props.onClick}
         ref={innerRef}
+        style={{
+          visibility:
+            props.visible || props.visible == null ? "visible" : "hidden",
+        }}
       >
         <div className="ToolIcon__icon" aria-hidden="true">
           {props.icon || props.label}

+ 18 - 10
src/components/ToolIcon.scss

@@ -5,6 +5,7 @@
   font-family: Cascadia;
   cursor: pointer;
   background-color: #e9ecef;
+  -webkit-tap-highlight-color: transparent;
 }
 
 .ToolIcon__icon {
@@ -69,17 +70,8 @@
   }
 }
 
-.ToolIcon.ToolIcon__lock {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-left: 0.1rem;
+.ToolIcon_type_floating {
   background-color: transparent;
-
-  .ToolIcon__icon {
-    width: 2rem;
-    height: 2em;
-  }
   &:hover {
     background-color: transparent;
   }
@@ -89,6 +81,22 @@
   &:focus {
     box-shadow: none;
   }
+  .ToolIcon__icon {
+    width: 2rem;
+    height: 2em;
+  }
+}
+
+.ToolIcon.ToolIcon__lock {
+  &.ToolIcon_type_button {
+    border-radius: 4px;
+    svg {
+      position: static;
+    }
+  }
+  &.ToolIcon_type_floating {
+    margin-left: 0.1rem;
+  }
 }
 
 .ToolIcon__keybinding {

+ 54 - 69
src/components/icons.tsx

@@ -5,92 +5,77 @@
 
 import React from "react";
 
-export const link = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512">
-    <path
-      fill="currentColor"
-      d="M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z"
-    />
+const createIcon = (d: string, width = 512) => (
+  <svg
+    aria-hidden="true"
+    focusable="false"
+    role="img"
+    viewBox={`0 0 ${width} 512`}
+  >
+    <path fill="currentColor" d={d} />
   </svg>
 );
 
-export const save = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
-    <path
-      fill="currentColor"
-      d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
-    />
-  </svg>
+export const link = createIcon(
+  "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z",
 );
 
-export const load = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
-    <path
-      fill="currentColor"
-      d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
-    />
-  </svg>
+export const save = createIcon(
+  "M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z",
+  448,
 );
 
-export const image = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
-    <path
-      fill="currentColor"
-      d="M384 121.941V128H256V0h6.059a24 24 0 0 1 16.97 7.029l97.941 97.941a24.002 24.002 0 0 1 7.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
-    />
-  </svg>
+export const load = createIcon(
+  "M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z",
+  576,
 );
 
-export const clipboard = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
-    <path
-      fill="currentColor"
-      d="M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z"
-    />
-  </svg>
+export const image = createIcon(
+  "M384 121.941V128H256V0h6.059a24 24 0 0 1 16.97 7.029l97.941 97.941a24.002 24.002 0 0 1 7.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z",
+  384,
 );
 
-export const trash = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
-    <path
-      fill="currentColor"
-      d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
-    />
-  </svg>
+export const clipboard = createIcon(
+  "M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z",
+  384,
 );
 
-export const palete = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512">
-    <path
-      fill="currentColor"
-      d="M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"
-    />
-  </svg>
+export const trash = createIcon(
+  "M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z",
+  448,
 );
 
-export const exportFile = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
-    <path
-      fill="currentColor"
-      d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z"
-    />
-  </svg>
+export const palette = createIcon(
+  "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z",
 );
 
-export const zoomIn = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
-    <path
-      fill="currentColor"
-      d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
-    />
-  </svg>
+export const exportFile = createIcon(
+  "M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z",
+  576,
 );
 
-export const zoomOut = (
-  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
-    <path
-      fill="currentColor"
-      d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
-    />
-  </svg>
+export const zoomIn = createIcon(
+  "M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z",
+  448,
+);
+
+export const zoomOut = createIcon(
+  "M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z",
+  448,
+);
+
+export const done = createIcon(
+  "M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z",
+);
+
+export const menu = createIcon(
+  "M16 132h416c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H16C7.163 60 0 67.163 0 76v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z",
+);
+
+export const undo = createIcon(
+  "M255.545 8c-66.269.119-126.438 26.233-170.86 68.685L48.971 40.971C33.851 25.851 8 36.559 8 57.941V192c0 13.255 10.745 24 24 24h134.059c21.382 0 32.09-25.851 16.971-40.971l-41.75-41.75c30.864-28.899 70.801-44.907 113.23-45.273 92.398-.798 170.283 73.977 169.484 169.442C423.236 348.009 349.816 424 256 424c-41.127 0-79.997-14.678-110.63-41.556-4.743-4.161-11.906-3.908-16.368.553L89.34 422.659c-4.872 4.872-4.631 12.815.482 17.433C133.798 479.813 192.074 504 256 504c136.966 0 247.999-111.033 248-247.998C504.001 119.193 392.354 7.755 255.545 8z",
+);
+
+export const redo = createIcon(
+  "M256.455 8c66.269.119 126.437 26.233 170.859 68.685l35.715-35.715C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.75c-30.864-28.899-70.801-44.907-113.23-45.273-92.398-.798-170.283 73.977-169.484 169.442C88.764 348.009 162.184 424 256 424c41.127 0 79.997-14.678 110.629-41.556 4.743-4.161 11.906-3.908 16.368.553l39.662 39.662c4.872 4.872 4.631 12.815-.482 17.433C378.202 479.813 319.926 504 256 504 119.034 504 8.001 392.967 8 256.002 7.999 119.193 119.646 7.755 256.455 8z",
 );

+ 36 - 23
src/element/handlerRectangles.ts

@@ -1,15 +1,26 @@
-import { ExcalidrawElement } from "./types";
+import { ExcalidrawElement, PointerType } from "./types";
 
 import { getElementAbsoluteCoords } from "./bounds";
 
 type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
 
-export function handlerRectangles(element: ExcalidrawElement, zoom: number) {
-  const handlerWidth = 8 / zoom;
-  const handlerHeight = 8 / zoom;
+const handleSizes: { [k in PointerType]: number } = {
+  mouse: 8,
+  pen: 16,
+  touch: 28,
+};
 
-  const handlerMarginX = 8 / zoom;
-  const handlerMarginY = 8 / zoom;
+export function handlerRectangles(
+  element: ExcalidrawElement,
+  zoom: number,
+  pointerType: PointerType = "mouse",
+) {
+  const size = handleSizes[pointerType];
+  const handlerWidth = size / zoom;
+  const handlerHeight = size / zoom;
+
+  const handlerMarginX = size / zoom;
+  const handlerMarginY = size / zoom;
 
   const [elementX1, elementY1, elementX2, elementY2] = getElementAbsoluteCoords(
     element,
@@ -20,59 +31,61 @@ export function handlerRectangles(element: ExcalidrawElement, zoom: number) {
 
   const dashedLineMargin = 4 / zoom;
 
+  const centeringOffset = (size - 8) / (2 * zoom);
+
   const handlers = {
     nw: [
-      elementX1 - dashedLineMargin - handlerMarginX,
-      elementY1 - dashedLineMargin - handlerMarginY,
+      elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
+      elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
     ],
     ne: [
-      elementX2 + dashedLineMargin,
-      elementY1 - dashedLineMargin - handlerMarginY,
+      elementX2 + dashedLineMargin - centeringOffset,
+      elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
     ],
     sw: [
-      elementX1 - dashedLineMargin - handlerMarginX,
-      elementY2 + dashedLineMargin,
+      elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
+      elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
     ],
     se: [
-      elementX2 + dashedLineMargin,
-      elementY2 + dashedLineMargin,
+      elementX2 + dashedLineMargin - centeringOffset,
+      elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
     ],
   } as { [T in Sides]: number[] };
 
   // We only want to show height handlers (all cardinal directions)  above a certain size
-  const minimumSizeForEightHandlers = 40 / zoom;
+  const minimumSizeForEightHandlers = (5 * size) / zoom;
   if (Math.abs(elementWidth) > minimumSizeForEightHandlers) {
     handlers["n"] = [
-      elementX1 + elementWidth / 2,
-      elementY1 - dashedLineMargin - handlerMarginY,
+      elementX1 + elementWidth / 2 - handlerWidth / 2,
+      elementY1 - dashedLineMargin - handlerMarginY + centeringOffset,
       handlerWidth,
       handlerHeight,
     ];
     handlers["s"] = [
-      elementX1 + elementWidth / 2,
-      elementY2 + dashedLineMargin,
+      elementX1 + elementWidth / 2 - handlerWidth / 2,
+      elementY2 + dashedLineMargin - centeringOffset,
       handlerWidth,
       handlerHeight,
     ];
   }
   if (Math.abs(elementHeight) > minimumSizeForEightHandlers) {
     handlers["w"] = [
-      elementX1 - dashedLineMargin - handlerMarginX,
-      elementY1 + elementHeight / 2,
+      elementX1 - dashedLineMargin - handlerMarginX + centeringOffset,
+      elementY1 + elementHeight / 2 - handlerHeight / 2,
       handlerWidth,
       handlerHeight,
     ];
     handlers["e"] = [
-      elementX2 + dashedLineMargin,
-      elementY1 + elementHeight / 2,
+      elementX2 + dashedLineMargin - centeringOffset,
+      elementY1 + elementHeight / 2 - handlerHeight / 2,
       handlerWidth,
       handlerHeight,
     ];

+ 5 - 3
src/element/resizeTest.ts

@@ -1,4 +1,4 @@
-import { ExcalidrawElement } from "./types";
+import { ExcalidrawElement, PointerType } from "./types";
 
 import { handlerRectangles } from "./handlerRectangles";
 
@@ -9,12 +9,13 @@ export function resizeTest(
   x: number,
   y: number,
   zoom: number,
+  pointerType: PointerType,
 ): HandlerRectanglesRet | false {
   if (!element.isSelected || element.type === "text") {
     return false;
   }
 
-  const handlers = handlerRectangles(element, zoom);
+  const handlers = handlerRectangles(element, zoom, pointerType);
 
   const filter = Object.keys(handlers).filter(key => {
     const handler = handlers[key as HandlerRectanglesRet]!;
@@ -41,12 +42,13 @@ export function getElementWithResizeHandler(
   elements: readonly ExcalidrawElement[],
   { x, y }: { x: number; y: number },
   zoom: number,
+  pointerType: PointerType,
 ) {
   return elements.reduce((result, element) => {
     if (result) {
       return result;
     }
-    const resizeHandle = resizeTest(element, x, y, zoom);
+    const resizeHandle = resizeTest(element, x, y, zoom, pointerType);
     return resizeHandle ? { element, resizeHandle } : null;
   }, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
 }

+ 1 - 1
src/element/textWysiwyg.tsx

@@ -34,7 +34,7 @@ export function textWysiwyg({
   // But this solution has an issue — it allows to paste
   // multiline text, which is not currently supported
   const editable = document.createElement("div");
-  editable.contentEditable = "true";
+  editable.contentEditable = "plaintext-only";
   editable.tabIndex = 0;
   editable.innerText = initText;
   editable.dataset.type = "wysiwyg";

+ 2 - 0
src/element/types.ts

@@ -9,3 +9,5 @@ export type ExcalidrawTextElement = ExcalidrawElement & {
   actualBoundingBoxAscent?: number;
   baseline: number;
 };
+
+export type PointerType = "mouse" | "pen" | "touch";

+ 92 - 77
src/index.tsx

@@ -109,6 +109,7 @@ import useIsMobile, { IsMobileProvider } from "./is-mobile";
 import { copyToAppClipboard, getClipboardContent } from "./clipboard";
 import { normalizeScroll } from "./scene/data";
 import { getCenter, getDistance } from "./gesture";
+import { menu, palette } from "./components/icons";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -286,9 +287,11 @@ const LayerUI = React.memo(
       );
     }
 
-    const showSelectedShapeActions =
-      (appState.editingElement || getSelectedElements(elements).length) &&
-      appState.elementType === "selection";
+    const showSelectedShapeActions = Boolean(
+      appState.editingElement ||
+        getSelectedElements(elements).length ||
+        appState.elementType !== "selection",
+    );
 
     function renderSelectedShapeActions() {
       const { elementType, editingElement } = appState;
@@ -386,21 +389,6 @@ const LayerUI = React.memo(
       );
     }
 
-    const lockButton = (
-      <LockIcon
-        checked={appState.elementLocked}
-        onChange={() => {
-          setAppState({
-            elementLocked: !appState.elementLocked,
-            elementType: appState.elementLocked
-              ? "selection"
-              : appState.elementType,
-          });
-        }}
-        title={t("toolBar.lock")}
-      />
-    );
-
     return isMobile ? (
       <>
         {appState.openedMenu === "canvas" ? (
@@ -411,13 +399,24 @@ const LayerUI = React.memo(
             <h2 className="visually-hidden" id="canvas-actions-title">
               {t("headings.canvasActions")}
             </h2>
-            <div className="App-mobile-menu-scroller">
+            <div className="App-mobile-menu-scroller panelColumn">
               <Stack.Col gap={4}>
                 {actionManager.renderAction("loadScene")}
                 {actionManager.renderAction("saveScene")}
                 {renderExportDialog()}
                 {actionManager.renderAction("clearCanvas")}
                 {actionManager.renderAction("changeViewBackgroundColor")}
+                <fieldset>
+                  <legend>{t("labels.language")}</legend>
+                  <LanguageList
+                    onChange={lng => {
+                      setLanguage(lng);
+                      setAppState({});
+                    }}
+                    languages={languages}
+                    currentLanguage={language}
+                  />
+                </fieldset>
               </Stack.Col>
             </div>
           </section>
@@ -456,61 +455,57 @@ const LayerUI = React.memo(
         </FixedSideContainer>
         <footer className="App-toolbar">
           <div className="App-toolbar-content">
-            <ToolButton
-              type="button"
-              icon={
-                <span style={{ fontSize: "2em", marginTop: "-0.15em" }}>☰</span>
-              }
-              aria-label={t("buttons.menu")}
-              onClick={() =>
-                setAppState(({ openedMenu }: any) => ({
-                  openedMenu: openedMenu === "canvas" ? null : "canvas",
-                }))
-              }
-            />
-            <div
-              style={{
-                visibility: isSomeElementSelected(elements)
-                  ? "visible"
-                  : "hidden",
-              }}
-            >
-              {" "}
-              {actionManager.renderAction("deleteSelectedElements")}
-            </div>
-            {lockButton}
-            {actionManager.renderAction("finalize")}
-            <div
-              style={{
-                visibility: isSomeElementSelected(elements)
-                  ? "visible"
-                  : "hidden",
-              }}
-            >
-              <ToolButton
-                type="button"
-                icon={
-                  <span style={{ fontSize: "2em", marginTop: "-0.15em" }}>
-                    ✎
-                  </span>
-                }
-                aria-label={t("buttons.menu")}
-                onClick={() =>
-                  setAppState(({ openedMenu }: any) => ({
-                    openedMenu: openedMenu === "shape" ? null : "shape",
-                  }))
-                }
-              />
-            </div>
-            {appState.scrolledOutside && (
-              <button
-                className="scroll-back-to-content"
-                onClick={() => {
-                  setAppState({ ...calculateScrollCenter(elements) });
-                }}
-              >
-                {t("buttons.scrollBackToContent")}
-              </button>
+            {appState.multiElement ? (
+              <>
+                {actionManager.renderAction("deleteSelectedElements")}
+                <ToolButton
+                  visible={showSelectedShapeActions}
+                  type="button"
+                  icon={palette}
+                  aria-label={t("buttons.edit")}
+                  onClick={() =>
+                    setAppState(({ openedMenu }: any) => ({
+                      openedMenu: openedMenu === "shape" ? null : "shape",
+                    }))
+                  }
+                />
+                {actionManager.renderAction("finalize")}
+              </>
+            ) : (
+              <>
+                <ToolButton
+                  type="button"
+                  icon={menu}
+                  aria-label={t("buttons.menu")}
+                  onClick={() =>
+                    setAppState(({ openedMenu }: any) => ({
+                      openedMenu: openedMenu === "canvas" ? null : "canvas",
+                    }))
+                  }
+                />
+                <ToolButton
+                  visible={showSelectedShapeActions}
+                  type="button"
+                  icon={palette}
+                  aria-label={t("buttons.edit")}
+                  onClick={() =>
+                    setAppState(({ openedMenu }: any) => ({
+                      openedMenu: openedMenu === "shape" ? null : "shape",
+                    }))
+                  }
+                />
+                {actionManager.renderAction("deleteSelectedElements")}
+                {appState.scrolledOutside && (
+                  <button
+                    className="scroll-back-to-content"
+                    onClick={() => {
+                      setAppState({ ...calculateScrollCenter(elements) });
+                    }}
+                  >
+                    {t("buttons.scrollBackToContent")}
+                  </button>
+                )}
+              </>
             )}
           </div>
         </footer>
@@ -545,7 +540,7 @@ const LayerUI = React.memo(
                   </Stack.Col>
                 </Island>
               </section>
-              {showSelectedShapeActions ? (
+              {showSelectedShapeActions && (
                 <section
                   className="App-right-menu"
                   aria-labelledby="selected-shape-title"
@@ -555,7 +550,7 @@ const LayerUI = React.memo(
                   </h2>
                   <Island padding={4}>{renderSelectedShapeActions()}</Island>
                 </section>
-              ) : null}
+              )}
             </Stack.Col>
             <section aria-labelledby="shapes-title">
               <Stack.Col gap={4} align="start">
@@ -566,7 +561,19 @@ const LayerUI = React.memo(
                     </h2>
                     <Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
                   </Island>
-                  {lockButton}
+                  <LockIcon
+                    checked={appState.elementLocked}
+                    onChange={() => {
+                      setAppState({
+                        elementLocked: !appState.elementLocked,
+                        elementType: appState.elementLocked
+                          ? "selection"
+                          : appState.elementType,
+                      });
+                    }}
+                    title={t("toolBar.lock")}
+                    isButton={isMobile}
+                  />
                 </Stack.Row>
               </Stack.Col>
             </section>
@@ -591,6 +598,7 @@ const LayerUI = React.memo(
             }}
             languages={languages}
             currentLanguage={language}
+            floating
           />
           {appState.scrolledOutside && (
             <button
@@ -1085,6 +1093,8 @@ export class App extends React.Component<any, AppState> {
                 return;
               }
 
+              this.setState({ lastPointerDownWith: e.pointerType });
+
               // pan canvas on wheel button drag or space+drag
               if (
                 gesture.pointers.length === 0 &&
@@ -1213,6 +1223,7 @@ export class App extends React.Component<any, AppState> {
                   elements,
                   { x, y },
                   this.state.zoom,
+                  e.pointerType,
                 );
 
                 const selectedElements = getSelectedElements(elements);
@@ -1279,6 +1290,9 @@ export class App extends React.Component<any, AppState> {
                 if (this.state.editingElement?.type === "text") {
                   return;
                 }
+                if (elementIsAddedToSelection) {
+                  element = hitElement!;
+                }
                 let textX = e.clientX;
                 let textY = e.clientY;
                 if (!e.altKey) {
@@ -2152,6 +2166,7 @@ export class App extends React.Component<any, AppState> {
                   elements,
                   { x, y },
                   this.state.zoom,
+                  e.pointerType,
                 );
                 if (resizeElement && resizeElement.resizeHandle) {
                   document.documentElement.style.cursor = getCursorForResizingElement(

+ 4 - 2
src/locales/en.json

@@ -40,7 +40,8 @@
     "colorPicker": "Color picker",
     "canvasBackground": "Canvas background",
     "drawingCanvas": "Drawing Canvas",
-    "layers": "Layers"
+    "layers": "Layers",
+    "language": "Language"
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -57,7 +58,8 @@
     "zoomIn": "Zoom in",
     "zoomOut": "Zoom out",
     "menu": "Menu",
-    "done": "Done"
+    "done": "Done",
+    "edit": "Edit"
   },
   "alerts": {
     "clearReset": "This will clear the whole canvas. Are you sure?",

+ 27 - 25
src/styles.scss

@@ -6,6 +6,11 @@ body {
   font-family: var(--ui-font);
   color: var(--text-color-primary);
   -webkit-text-size-adjust: 100%;
+  user-select: none;
+}
+[contenteditable] {
+  user-select: auto;
+  cursor: text;
 }
 
 canvas {
@@ -139,11 +144,19 @@ button,
   }
 }
 
+.App-toolbar,
+.App-mobile-menu {
+  --spacing: 0.5rem;
+  --padding: calc(4 * var(--space-factor));
+  padding: var(--padding);
+  padding-left: #{"max(var(--padding), env(safe-area-inset-left))"};
+  padding-right: #{"max(var(--padding), env(safe-area-inset-right))"};
+  background: #fcfcfc;
+  border-top: 1px solid #ccc;
+  box-sizing: border-box;
+}
 .App-toolbar {
-  padding: var(--spacing);
-  padding-bottom: #{"max(var(--spacing), env(safe-area-inset-bottom))"};
-  padding-left: #{"max(var(--spacing), env(safe-area-inset-left))"};
-  padding-right: #{"max(var(--spacing), env(safe-area-inset-right))"};
+  padding-bottom: #{"max(var(--padding), env(safe-area-inset-bottom))"};
   width: 100%;
   box-sizing: border-box;
   overflow: auto;
@@ -155,14 +168,8 @@ button,
   align-items: center;
   justify-content: space-between;
 }
-.App-toolbar,
 .App-mobile-menu {
-  --spacing: 0.5rem;
-  background: #fcfcfc;
-  border-top: 1px solid #ccc;
-}
-.App-mobile-menu {
-  --bottom: calc(3rem - 1px + max(var(--spacing), env(safe-area-inset-bottom)));
+  --bottom: calc(3rem - 1px + max(var(--padding), env(safe-area-inset-bottom)));
   display: grid;
   position: fixed;
   width: 100%;
@@ -174,10 +181,6 @@ button,
 .App-mobile-menu .App-mobile-menu-scroller {
   background: #fcfcfc;
   box-shadow: none;
-  --padding: calc(4 * var(--space-factor));
-  padding: var(--padding);
-  padding-left: #{"max(var(--padding), env(safe-area-inset-left))"};
-  padding-right: #{"max(var(--padding), env(safe-area-inset-right))"};
 }
 
 .App-menu {
@@ -288,12 +291,7 @@ button,
 }
 
 .dropdown-select {
-  position: absolute;
-  margin-bottom: 0.5em;
-  margin-right: 0.5em;
   height: 1.5rem;
-  right: 0;
-  bottom: 0;
   padding: 0 1.5rem 0 0.5rem;
   background-color: #e9ecef;
   border-radius: var(--space-factor);
@@ -317,10 +315,14 @@ button,
   &:active {
     background-color: #ced4da;
   }
+  &.dropdown-select--floating {
+    position: absolute;
+    margin-bottom: 0.5em;
+    margin-right: 0.5em;
+  }
 }
 
-.language-select {
-  @extend .dropdown-select;
+.dropdown-select__language.dropdown-select--floating {
   right: 0;
   bottom: 0;
 }
@@ -351,7 +353,7 @@ button,
 .scroll-back-to-content {
   position: fixed;
   left: 50%;
-  bottom: 20px;
+  bottom: 30px;
   transform: translateX(-50%);
   padding: 10px 20px;
 }
@@ -361,7 +363,7 @@ button,
     display: none;
   }
   .scroll-back-to-content {
-    bottom: 70px;
-    bottom: calc(70px + env(safe-area-inset-bottom));
+    bottom: 80px;
+    bottom: calc(80px + env(safe-area-inset-bottom));
   }
 }

+ 2 - 1
src/types.ts

@@ -1,4 +1,4 @@
-import { ExcalidrawElement } from "./element/types";
+import { ExcalidrawElement, PointerType } from "./element/types";
 import { SHAPES } from "./shapes";
 
 export type FlooredNumber = number & { _brand: "FlooredNumber" };
@@ -32,6 +32,7 @@ export type AppState = {
   isResizing: boolean;
   zoom: number;
   openedMenu: "canvas" | "shape" | null;
+  lastPointerDownWith: PointerType;
 };
 
 export type Pointer = Readonly<{