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
This commit is contained in:
Jed Fox 2020-02-21 14:34:18 -05:00 committed by GitHub
parent 949c3841ea
commit 0fd3fb4b5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 272 additions and 222 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,92 +5,77 @@
import React from "react"; import React from "react";
export const link = ( const createIcon = (d: string, width = 512) => (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512"> <svg
<path aria-hidden="true"
fill="currentColor" focusable="false"
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" role="img"
/> viewBox={`0 0 ${width} 512`}
>
<path fill="currentColor" d={d} />
</svg> </svg>
); );
export const save = ( export const link = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512"> "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",
<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 load = ( export const save = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512"> "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",
<path 448,
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 image = ( export const load = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512"> "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",
<path 576,
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 clipboard = ( export const image = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512"> "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",
<path 384,
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 trash = ( export const clipboard = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512"> "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",
<path 384,
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 palete = ( export const trash = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512"> "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",
<path 448,
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 exportFile = ( export const palette = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512"> "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",
<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 zoomIn = ( export const exportFile = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512"> "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",
<path 576,
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 zoomOut = ( export const zoomIn = createIcon(
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512"> "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",
<path 448,
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"
/> export const zoomOut = createIcon(
</svg> "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",
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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