Initial support for mobile devices (#787)
* Initial support for mobile devices No editing yet, but UI looks nice and you can open the canvas menu * Add support for editing shape color, etc * Allow the mobile menus to cover the shape selector * Hopefully fix test error * Fix touch on canvas * Fix safe area handling & remove unused Island
This commit is contained in:
parent
9439908b92
commit
7a7a73b78d
@ -5,7 +5,7 @@
|
|||||||
<title>Excalidraw</title>
|
<title>Excalidraw</title>
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<!-- prettier-ignore -->
|
<!-- prettier-ignore -->
|
||||||
|
@ -7,6 +7,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { getNormalizedZoom } from "../scene";
|
import { getNormalizedZoom } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor: Action = {
|
export const actionChangeViewBackgroundColor: Action = {
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@ -43,6 +44,7 @@ export const actionClearCanvas: Action = {
|
|||||||
icon={trash}
|
icon={trash}
|
||||||
title={t("buttons.clearReset")}
|
title={t("buttons.clearReset")}
|
||||||
aria-label={t("buttons.clearReset")}
|
aria-label={t("buttons.clearReset")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (window.confirm(t("alerts.clearReset"))) {
|
if (window.confirm(t("alerts.clearReset"))) {
|
||||||
// TODO: Defined globally, since file handles aren't yet serializable.
|
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||||
|
@ -5,6 +5,7 @@ import { saveAsJSON, loadFromJSON } from "../scene";
|
|||||||
import { load, save } from "../components/icons";
|
import { load, save } from "../components/icons";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
|
||||||
export const actionChangeProjectName: Action = {
|
export const actionChangeProjectName: Action = {
|
||||||
name: "changeProjectName",
|
name: "changeProjectName",
|
||||||
@ -51,6 +52,7 @@ export const actionSaveScene: Action = {
|
|||||||
icon={save}
|
icon={save}
|
||||||
title={t("buttons.save")}
|
title={t("buttons.save")}
|
||||||
aria-label={t("buttons.save")}
|
aria-label={t("buttons.save")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@ -71,6 +73,7 @@ export const actionLoadScene: Action = {
|
|||||||
icon={load}
|
icon={load}
|
||||||
title={t("buttons.load")}
|
title={t("buttons.load")}
|
||||||
aria-label={t("buttons.load")}
|
aria-label={t("buttons.load")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loadFromJSON()
|
loadFromJSON()
|
||||||
.then(({ elements, appState }) => {
|
.then(({ elements, appState }) => {
|
||||||
|
@ -29,6 +29,7 @@ export function getDefaultAppState(): AppState {
|
|||||||
isResizing: false,
|
isResizing: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
zoom: 1,
|
zoom: 1,
|
||||||
|
openedMenu: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +98,9 @@
|
|||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
border-radius: 0px 4px 4px 0px;
|
border-radius: 0px 4px 4px 0px;
|
||||||
float: left;
|
float: left;
|
||||||
|
padding: 1px;
|
||||||
padding-left: 0.5em;
|
padding-left: 0.5em;
|
||||||
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-label-swatch {
|
.color-picker-label-swatch {
|
||||||
|
@ -17,6 +17,7 @@ import { KEYS } from "../keys";
|
|||||||
|
|
||||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
|
import useIsMobile from "../is-mobile";
|
||||||
|
|
||||||
const scales = [1, 2, 3];
|
const scales = [1, 2, 3];
|
||||||
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
|
||||||
@ -233,6 +234,7 @@ export function ExportDialog({
|
|||||||
icon={exportFile}
|
icon={exportFile}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("buttons.export")}
|
aria-label={t("buttons.export")}
|
||||||
|
showAriaLabel={useIsMobile()}
|
||||||
title={t("buttons.export")}
|
title={t("buttons.export")}
|
||||||
ref={triggerButton}
|
ref={triggerButton}
|
||||||
/>
|
/>
|
||||||
|
@ -14,6 +14,7 @@ type ToolButtonBaseProps = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
size?: ToolIconSize;
|
size?: ToolIconSize;
|
||||||
keyBindingLabel?: string;
|
keyBindingLabel?: string;
|
||||||
|
showAriaLabel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToolButtonProps =
|
type ToolButtonProps =
|
||||||
@ -48,6 +49,9 @@ export const ToolButton = React.forwardRef(function(
|
|||||||
<div className="ToolIcon__icon" aria-hidden="true">
|
<div className="ToolIcon__icon" aria-hidden="true">
|
||||||
{props.icon || props.label}
|
{props.icon || props.label}
|
||||||
</div>
|
</div>
|
||||||
|
{props.showAriaLabel && (
|
||||||
|
<div className="ToolIcon__label">{props["aria-label"]}</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
.ToolIcon {
|
.ToolIcon {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
font-family: Cascadia;
|
font-family: Cascadia;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background-color: #e9ecef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: #e9ecef;
|
|
||||||
|
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
|
|
||||||
@ -23,6 +23,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ToolIcon__label {
|
||||||
|
font-family: var(--ui-font);
|
||||||
|
}
|
||||||
|
|
||||||
.ToolIcon_size_s .ToolIcon__icon {
|
.ToolIcon_size_s .ToolIcon__icon {
|
||||||
width: 1.4rem;
|
width: 1.4rem;
|
||||||
height: 1.4rem;
|
height: 1.4rem;
|
||||||
@ -35,13 +39,13 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
||||||
&:hover .ToolIcon__icon {
|
&:hover {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
}
|
}
|
||||||
&:active .ToolIcon__icon {
|
&:active {
|
||||||
background-color: #ced4da;
|
background-color: #ced4da;
|
||||||
}
|
}
|
||||||
&:focus .ToolIcon__icon {
|
&:focus {
|
||||||
box-shadow: 0 0 0 2px #a5d8ff;
|
box-shadow: 0 0 0 2px #a5d8ff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,19 +74,19 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 0.1rem;
|
margin-left: 0.1rem;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
.ToolIcon__icon {
|
.ToolIcon__icon {
|
||||||
background-color: transparent;
|
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
}
|
}
|
||||||
&:hover .ToolIcon__icon {
|
&:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
&:active .ToolIcon__icon {
|
&:active {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
&:focus .ToolIcon__icon {
|
&:focus {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -93,6 +97,6 @@
|
|||||||
right: 3px;
|
right: 3px;
|
||||||
font-size: 0.5em;
|
font-size: 0.5em;
|
||||||
color: #adb5bd; // OC GRAY 5
|
color: #adb5bd; // OC GRAY 5
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--ui-font);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
346
src/index.tsx
346
src/index.tsx
@ -104,6 +104,7 @@ import { LanguageList } from "./components/LanguageList";
|
|||||||
import { Point } from "roughjs/bin/geometry";
|
import { Point } from "roughjs/bin/geometry";
|
||||||
import { t, languages, setLanguage, getLanguage } from "./i18n";
|
import { t, languages, setLanguage, getLanguage } from "./i18n";
|
||||||
import { HintViewer } from "./components/HintViewer";
|
import { HintViewer } from "./components/HintViewer";
|
||||||
|
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";
|
||||||
@ -135,6 +136,18 @@ const MOUSE_BUTTON = {
|
|||||||
SECONDARY: 2,
|
SECONDARY: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Block pinch-zooming on iOS outside of the content area
|
||||||
|
document.addEventListener(
|
||||||
|
"touchmove",
|
||||||
|
function(event) {
|
||||||
|
// @ts-ignore
|
||||||
|
if (event.scale !== 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ passive: false },
|
||||||
|
);
|
||||||
|
|
||||||
let lastMouseUp: ((e: any) => void) | null = null;
|
let lastMouseUp: ((e: any) => void) | null = null;
|
||||||
|
|
||||||
export function viewportCoordsToSceneCoords(
|
export function viewportCoordsToSceneCoords(
|
||||||
@ -211,64 +224,58 @@ const LayerUI = React.memo(
|
|||||||
language,
|
language,
|
||||||
setElements,
|
setElements,
|
||||||
}: LayerUIProps) => {
|
}: LayerUIProps) => {
|
||||||
function renderCanvasActions() {
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
function renderExportDialog() {
|
||||||
return (
|
return (
|
||||||
<Stack.Col gap={4}>
|
<ExportDialog
|
||||||
<Stack.Row justifyContent={"space-between"}>
|
elements={elements}
|
||||||
{actionManager.renderAction("loadScene")}
|
appState={appState}
|
||||||
{actionManager.renderAction("saveScene")}
|
actionManager={actionManager}
|
||||||
<ExportDialog
|
onExportToPng={(exportedElements, scale) => {
|
||||||
elements={elements}
|
if (canvas) {
|
||||||
appState={appState}
|
exportCanvas("png", exportedElements, canvas, {
|
||||||
actionManager={actionManager}
|
exportBackground: appState.exportBackground,
|
||||||
onExportToPng={(exportedElements, scale) => {
|
name: appState.name,
|
||||||
if (canvas) {
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
exportCanvas("png", exportedElements, canvas, {
|
scale,
|
||||||
exportBackground: appState.exportBackground,
|
});
|
||||||
name: appState.name,
|
}
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
}}
|
||||||
scale,
|
onExportToSvg={(exportedElements, scale) => {
|
||||||
});
|
if (canvas) {
|
||||||
}
|
exportCanvas("svg", exportedElements, canvas, {
|
||||||
}}
|
exportBackground: appState.exportBackground,
|
||||||
onExportToSvg={(exportedElements, scale) => {
|
name: appState.name,
|
||||||
if (canvas) {
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
exportCanvas("svg", exportedElements, canvas, {
|
scale,
|
||||||
exportBackground: appState.exportBackground,
|
});
|
||||||
name: appState.name,
|
}
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
}}
|
||||||
scale,
|
onExportToClipboard={(exportedElements, scale) => {
|
||||||
});
|
if (canvas) {
|
||||||
}
|
exportCanvas("clipboard", exportedElements, canvas, {
|
||||||
}}
|
exportBackground: appState.exportBackground,
|
||||||
onExportToClipboard={(exportedElements, scale) => {
|
name: appState.name,
|
||||||
if (canvas) {
|
viewBackgroundColor: appState.viewBackgroundColor,
|
||||||
exportCanvas("clipboard", exportedElements, canvas, {
|
scale,
|
||||||
exportBackground: appState.exportBackground,
|
});
|
||||||
name: appState.name,
|
}
|
||||||
viewBackgroundColor: appState.viewBackgroundColor,
|
}}
|
||||||
scale,
|
onExportToBackend={exportedElements => {
|
||||||
});
|
if (canvas) {
|
||||||
}
|
exportCanvas(
|
||||||
}}
|
"backend",
|
||||||
onExportToBackend={exportedElements => {
|
exportedElements.map(element => ({
|
||||||
if (canvas) {
|
...element,
|
||||||
exportCanvas(
|
isSelected: false,
|
||||||
"backend",
|
})),
|
||||||
exportedElements.map(element => ({
|
canvas,
|
||||||
...element,
|
appState,
|
||||||
isSelected: false,
|
);
|
||||||
})),
|
}
|
||||||
canvas,
|
}}
|
||||||
appState,
|
/>
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{actionManager.renderAction("clearCanvas")}
|
|
||||||
</Stack.Row>
|
|
||||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
|
||||||
</Stack.Col>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,51 +291,49 @@ const LayerUI = React.memo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Island padding={4}>
|
<div className="panelColumn">
|
||||||
<div className="panelColumn">
|
{actionManager.renderAction("changeStrokeColor")}
|
||||||
{actionManager.renderAction("changeStrokeColor")}
|
{(hasBackground(elementType) ||
|
||||||
{(hasBackground(elementType) ||
|
targetElements.some(element => hasBackground(element.type))) && (
|
||||||
targetElements.some(element => hasBackground(element.type))) && (
|
<>
|
||||||
<>
|
{actionManager.renderAction("changeBackgroundColor")}
|
||||||
{actionManager.renderAction("changeBackgroundColor")}
|
|
||||||
|
|
||||||
{actionManager.renderAction("changeFillStyle")}
|
{actionManager.renderAction("changeFillStyle")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasStroke(elementType) ||
|
{(hasStroke(elementType) ||
|
||||||
targetElements.some(element => hasStroke(element.type))) && (
|
targetElements.some(element => hasStroke(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{actionManager.renderAction("changeStrokeWidth")}
|
{actionManager.renderAction("changeStrokeWidth")}
|
||||||
|
|
||||||
{actionManager.renderAction("changeSloppiness")}
|
{actionManager.renderAction("changeSloppiness")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(hasText(elementType) ||
|
{(hasText(elementType) ||
|
||||||
targetElements.some(element => hasText(element.type))) && (
|
targetElements.some(element => hasText(element.type))) && (
|
||||||
<>
|
<>
|
||||||
{actionManager.renderAction("changeFontSize")}
|
{actionManager.renderAction("changeFontSize")}
|
||||||
|
|
||||||
{actionManager.renderAction("changeFontFamily")}
|
{actionManager.renderAction("changeFontFamily")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actionManager.renderAction("changeOpacity")}
|
{actionManager.renderAction("changeOpacity")}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.layers")}</legend>
|
<legend>{t("labels.layers")}</legend>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
{actionManager.renderAction("sendToBack")}
|
{actionManager.renderAction("sendToBack")}
|
||||||
{actionManager.renderAction("sendBackward")}
|
{actionManager.renderAction("sendBackward")}
|
||||||
{actionManager.renderAction("bringToFront")}
|
{actionManager.renderAction("bringToFront")}
|
||||||
{actionManager.renderAction("bringForward")}
|
{actionManager.renderAction("bringForward")}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{actionManager.renderAction("deleteSelectedElements")}
|
{actionManager.renderAction("deleteSelectedElements")}
|
||||||
</div>
|
</div>
|
||||||
</Island>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -378,7 +383,125 @@ const LayerUI = React.memo(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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" ? (
|
||||||
|
<section
|
||||||
|
className="App-mobile-menu"
|
||||||
|
aria-labelledby="canvas-actions-title"
|
||||||
|
>
|
||||||
|
<h2 className="visually-hidden" id="canvas-actions-title">
|
||||||
|
{t("headings.canvasActions")}
|
||||||
|
</h2>
|
||||||
|
<div className="App-mobile-menu-scroller">
|
||||||
|
<Stack.Col gap={4}>
|
||||||
|
{actionManager.renderAction("loadScene")}
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{renderExportDialog()}
|
||||||
|
{actionManager.renderAction("clearCanvas")}
|
||||||
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
|
</Stack.Col>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : appState.openedMenu === "shape" ? (
|
||||||
|
<section
|
||||||
|
className="App-mobile-menu"
|
||||||
|
aria-labelledby="selected-shape-title"
|
||||||
|
>
|
||||||
|
<h2 className="visually-hidden" id="selected-shape-title">
|
||||||
|
{t("headings.selectedShapeActions")}
|
||||||
|
</h2>
|
||||||
|
<div className="App-mobile-menu-scroller">
|
||||||
|
{renderSelectedShapeActions(elements)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
<FixedSideContainer side="top">
|
||||||
|
<section aria-labelledby="shapes-title">
|
||||||
|
<Stack.Col gap={4} align="center">
|
||||||
|
<Stack.Row gap={1}>
|
||||||
|
<Island padding={1}>
|
||||||
|
<h2 className="visually-hidden" id="shapes-title">
|
||||||
|
{t("headings.shapes")}
|
||||||
|
</h2>
|
||||||
|
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
|
||||||
|
</Island>
|
||||||
|
</Stack.Row>
|
||||||
|
</Stack.Col>
|
||||||
|
</section>
|
||||||
|
</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",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{lockButton}
|
||||||
|
<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>
|
||||||
|
<HintViewer
|
||||||
|
elementType={appState.elementType}
|
||||||
|
multiMode={appState.multiElement !== null}
|
||||||
|
isResizing={appState.isResizing}
|
||||||
|
elements={elements}
|
||||||
|
/>
|
||||||
|
{appState.scrolledOutside && (
|
||||||
|
<button
|
||||||
|
className="scroll-back-to-content"
|
||||||
|
onClick={() => {
|
||||||
|
setAppState({ ...calculateScrollCenter(elements) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("buttons.scrollBackToContent")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
@ -390,7 +513,17 @@ 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>
|
||||||
<Island padding={4}>{renderCanvasActions()}</Island>
|
<Island padding={4}>
|
||||||
|
<Stack.Col gap={4}>
|
||||||
|
<Stack.Row justifyContent={"space-between"}>
|
||||||
|
{actionManager.renderAction("loadScene")}
|
||||||
|
{actionManager.renderAction("saveScene")}
|
||||||
|
{renderExportDialog()}
|
||||||
|
{actionManager.renderAction("clearCanvas")}
|
||||||
|
</Stack.Row>
|
||||||
|
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||||
|
</Stack.Col>
|
||||||
|
</Island>
|
||||||
</section>
|
</section>
|
||||||
<section
|
<section
|
||||||
className="App-right-menu"
|
className="App-right-menu"
|
||||||
@ -399,7 +532,9 @@ const LayerUI = React.memo(
|
|||||||
<h2 className="visually-hidden" id="selected-shape-title">
|
<h2 className="visually-hidden" id="selected-shape-title">
|
||||||
{t("headings.selectedShapeActions")}
|
{t("headings.selectedShapeActions")}
|
||||||
</h2>
|
</h2>
|
||||||
{renderSelectedShapeActions(elements)}
|
<Island padding={4}>
|
||||||
|
{renderSelectedShapeActions(elements)}
|
||||||
|
</Island>
|
||||||
</section>
|
</section>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
<section aria-labelledby="shapes-title">
|
<section aria-labelledby="shapes-title">
|
||||||
@ -411,18 +546,7 @@ const LayerUI = React.memo(
|
|||||||
</h2>
|
</h2>
|
||||||
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
|
<Stack.Row gap={1}>{renderShapesSwitcher()}</Stack.Row>
|
||||||
</Island>
|
</Island>
|
||||||
<LockIcon
|
{lockButton}
|
||||||
checked={appState.elementLocked}
|
|
||||||
onChange={() => {
|
|
||||||
setAppState({
|
|
||||||
elementLocked: !appState.elementLocked,
|
|
||||||
elementType: appState.elementLocked
|
|
||||||
? "selection"
|
|
||||||
: appState.elementType,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title={t("toolBar.lock")}
|
|
||||||
/>
|
|
||||||
</Stack.Row>
|
</Stack.Row>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
</section>
|
</section>
|
||||||
@ -2204,7 +2328,9 @@ class TopErrorBoundary extends React.Component {
|
|||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<TopErrorBoundary>
|
<TopErrorBoundary>
|
||||||
<App />
|
<IsMobileProvider>
|
||||||
|
<App />
|
||||||
|
</IsMobileProvider>
|
||||||
</TopErrorBoundary>,
|
</TopErrorBoundary>,
|
||||||
rootElement,
|
rootElement,
|
||||||
);
|
);
|
||||||
|
25
src/is-mobile.tsx
Normal file
25
src/is-mobile.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useContext } from "react";
|
||||||
|
|
||||||
|
const context = React.createContext(false);
|
||||||
|
|
||||||
|
export function IsMobileProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const query = useRef<MediaQueryList>();
|
||||||
|
if (!query.current) {
|
||||||
|
query.current = window.matchMedia(
|
||||||
|
"(max-width: 600px), (max-height: 500px)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const [isMobile, setMobile] = useState(query.current.matches);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => setMobile(query.current!.matches);
|
||||||
|
query.current!.addListener(handler);
|
||||||
|
return () => query.current!.removeListener(handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <context.Provider value={isMobile}>{children}</context.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useIsMobile() {
|
||||||
|
return useContext(context);
|
||||||
|
}
|
@ -43,7 +43,7 @@
|
|||||||
"layers": "Layers"
|
"layers": "Layers"
|
||||||
},
|
},
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"clearReset": "Clear the canvas & reset background color",
|
"clearReset": "Reset the canvas",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"exportToPng": "Export to PNG",
|
"exportToPng": "Export to PNG",
|
||||||
"exportToSvg": "Export to SVG",
|
"exportToSvg": "Export to SVG",
|
||||||
@ -55,7 +55,8 @@
|
|||||||
"selectLanguage": "Select Language",
|
"selectLanguage": "Select Language",
|
||||||
"scrollBackToContent": "Scroll back to content",
|
"scrollBackToContent": "Scroll back to content",
|
||||||
"zoomIn": "Zoom in",
|
"zoomIn": "Zoom in",
|
||||||
"zoomOut": "Zoom out"
|
"zoomOut": "Zoom out",
|
||||||
|
"menu": "Menu"
|
||||||
},
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"clearReset": "This will clear the whole canvas. Are you sure?",
|
"clearReset": "This will clear the whole canvas. Are you sure?",
|
||||||
|
@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--ui-font: Arial, Helvetica, sans-serif;
|
||||||
|
font-family: var(--ui-font);
|
||||||
color: var(--text-color-primary);
|
color: var(--text-color-primary);
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
// following props improve blurriness at certain devicePixelRatios.
|
// following props improve blurriness at certain devicePixelRatios.
|
||||||
// AFAIK it doesn't affect export (in fact, export seems sharp either way).
|
// AFAIK it doesn't affect export (in fact, export seems sharp either way).
|
||||||
|
|
||||||
@ -24,6 +29,11 @@ canvas {
|
|||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.panelColumn {
|
.panelColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -91,6 +101,7 @@ input:focus {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
.buttonList label {
|
.buttonList label {
|
||||||
|
user-select: none;
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -128,6 +139,47 @@ button,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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))"};
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.App-toolbar-content {
|
||||||
|
display: flex;
|
||||||
|
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)));
|
||||||
|
display: grid;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
bottom: var(--bottom);
|
||||||
|
z-index: 4;
|
||||||
|
max-height: calc(100% - var(--bottom));
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
.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 {
|
.App-menu {
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
@ -303,3 +355,13 @@ button,
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px), (max-height: 500px) {
|
||||||
|
aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scroll-back-to-content {
|
||||||
|
bottom: 70px;
|
||||||
|
bottom: calc(70px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -31,4 +31,5 @@ export type AppState = {
|
|||||||
selectedId?: string;
|
selectedId?: string;
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
openedMenu: "canvas" | "shape" | null;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user