mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-10-21 06:59:47 +02:00
Compare commits
60 Commits
export-com
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e68e11ff9f | ||
![]() |
b4750f4485 | ||
![]() |
4426275184 | ||
![]() |
2b4462c941 | ||
![]() |
43b13d8e3a | ||
![]() |
720f468f39 | ||
![]() |
33300d19f6 | ||
![]() |
5aed159991 | ||
![]() |
de1d221d1c | ||
![]() |
9a68dbffe2 | ||
![]() |
32d82219b1 | ||
![]() |
ba2c86fe1b | ||
![]() |
f1ae37c84b | ||
![]() |
ec350ba8b2 | ||
![]() |
46a61ad4df | ||
![]() |
f4b1a30bef | ||
![]() |
32aa79164b | ||
![]() |
b5fd904808 | ||
![]() |
8f8dd1105f | ||
![]() |
b914ad41fc | ||
![]() |
551c38f60b | ||
![]() |
38e8ae46c9 | ||
![]() |
ad0c4c4c78 | ||
![]() |
27cf5ed17e | ||
![]() |
fd946adbae | ||
![]() |
c37977af4b | ||
![]() |
a0d413ab4e | ||
![]() |
b67a2b4f65 | ||
![]() |
5a8dbe8030 | ||
![]() |
731093f631 | ||
![]() |
fe56975f19 | ||
![]() |
2d800feeeb | ||
![]() |
93cccd596a | ||
![]() |
45b592227d | ||
![]() |
b818df1098 | ||
![]() |
4359e2935d | ||
![]() |
3d9d398378 | ||
![]() |
0a5da0269f | ||
![]() |
08ce7c7fc3 | ||
![]() |
fe7fbff7f6 | ||
![]() |
501397cb61 | ||
![]() |
865d29388c | ||
![]() |
54c7ec416a | ||
![]() |
aca284057d | ||
![]() |
2820cd112e | ||
![]() |
426b5d9537 | ||
![]() |
e7d34677c6 | ||
![]() |
3d5356cb8e | ||
![]() |
46f5ce5ce0 | ||
![]() |
b00bd3d6c0 | ||
![]() |
91fc22182c | ||
![]() |
966ca2ffa6 | ||
![]() |
2b049b4a65 | ||
![]() |
339212e563 | ||
![]() |
f8b4bb66b4 | ||
![]() |
f4312bba5e | ||
![]() |
ac66665b64 | ||
![]() |
2b71a1f0bd | ||
![]() |
58845e450a | ||
![]() |
15d79f8fee |
15
.github/workflows/publish-docker.yml
vendored
15
.github/workflows/publish-docker.yml
vendored
@@ -10,11 +10,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/build-push-action@v2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
repository: excalidraw/excalidraw
|
||||
tag_with_ref: true
|
||||
tag_with_sha: true
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: excalidraw/excalidraw:latest
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,7 +19,6 @@ logs
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
package-lock.json
|
||||
static
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
src/packages/excalidraw/types
|
||||
|
20
dev-docs/.gitignore
vendored
Normal file
20
dev-docs/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
41
dev-docs/README.md
Normal file
41
dev-docs/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
3
dev-docs/babel.config.js
Normal file
3
dev-docs/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
6
dev-docs/docs/codebase/overview.md
Normal file
6
dev-docs/docs/codebase/overview.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Overview
|
||||
---
|
||||
|
||||
In development. For now, refer to [excalidraw Readme](https://github.com/excalidraw/excalidraw/blob/master/README.md).
|
8
dev-docs/docs/get-started.md
Normal file
8
dev-docs/docs/get-started.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Introduction
|
||||
---
|
||||
|
||||
Want to integrate Excalidraw into your app? Head over to the [package docs](/docs/package/overview).
|
||||
|
||||
If you're looking into the Excalidraw codebase itself, start [here](/docs/codebase/overview).
|
6
dev-docs/docs/package/overview.md
Normal file
6
dev-docs/docs/package/overview.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Overview
|
||||
---
|
||||
|
||||
In development. For now, refer to [excalidraw package readme](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md).
|
121
dev-docs/docusaurus.config.js
Normal file
121
dev-docs/docusaurus.config.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Excalidraw developer docs",
|
||||
tagline:
|
||||
"For Excalidraw contributors or those integrating the Excalidraw editor",
|
||||
url: "https://docs.excalidraw.com.com",
|
||||
baseUrl: "/",
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
favicon: "img/favicon.ico",
|
||||
organizationName: "Excalidraw", // Usually your GitHub org/user name.
|
||||
projectName: "excalidraw", // Usually your repo name.
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"],
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
// Please change this to your repo.
|
||||
editUrl: "https://github.com/excalidraw/docs/tree/master/",
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css"),
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
navbar: {
|
||||
title: "Excalidraw Docs",
|
||||
logo: {
|
||||
alt: "Excalidraw Logo",
|
||||
src: "img/logo.svg",
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "doc",
|
||||
docId: "get-started",
|
||||
position: "left",
|
||||
label: "Get started",
|
||||
},
|
||||
{
|
||||
to: "https://blog.excalidraw.com",
|
||||
label: "Blog",
|
||||
position: "left",
|
||||
},
|
||||
{
|
||||
to: "https://github.com/excalidraw/excalidraw",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: "dark",
|
||||
links: [
|
||||
{
|
||||
title: "Docs",
|
||||
items: [
|
||||
{
|
||||
label: "Get Started",
|
||||
to: "/docs/get-started",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Community",
|
||||
items: [
|
||||
{
|
||||
label: "Discord",
|
||||
href: "https://discord.gg/UexuTaE",
|
||||
},
|
||||
{
|
||||
label: "Twitter",
|
||||
href: "https://twitter.com/excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "More",
|
||||
items: [
|
||||
{
|
||||
label: "Blog",
|
||||
to: "https://blog.excalidraw.com",
|
||||
},
|
||||
{
|
||||
label: "GitHub",
|
||||
to: "https://github.com/excalidraw/excalidraw",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Made with ❤️ Built with Docusaurus`,
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
46
dev-docs/package.json
Normal file
46
dev-docs/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start --port 3003",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.0.0-rc.1",
|
||||
"@docusaurus/preset-classic": "2.0.0-rc.1",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.0.0-rc.1",
|
||||
"@tsconfig/docusaurus": "^1.0.5",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
}
|
||||
}
|
31
dev-docs/sidebars.js
Normal file
31
dev-docs/sidebars.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['hello'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
62
dev-docs/src/components/Homepage/index.js
Normal file
62
dev-docs/src/components/Homepage/index.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const FeatureList = [
|
||||
{
|
||||
title: "Learn how Excalidraw works",
|
||||
Svg: require("@site/static/img/undraw_innovative.svg").default,
|
||||
description: (
|
||||
<>Want to contribute to Excalidraw but got lost in the codebase?</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Integrate Excalidraw",
|
||||
Svg: require("@site/static/img/undraw_blank_canvas.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Want to build your own app powered by Excalidraw by don't know where to
|
||||
start?
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Help us improve",
|
||||
Svg: require("@site/static/img/undraw_add_files.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Are the docs missing something? Anything you had trouble understanding
|
||||
or needs an explanation? Come contribute to the docs to make them even
|
||||
better!
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({ Svg, title, description }) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures() {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
70
dev-docs/src/components/Homepage/index.tsx
Normal file
70
dev-docs/src/components/Homepage/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
Svg: React.ComponentType<React.ComponentProps<"svg">>;
|
||||
description: JSX.Element;
|
||||
};
|
||||
|
||||
const FeatureList: FeatureItem[] = [
|
||||
{
|
||||
title: "Easy to Use",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus was designed from the ground up to be easily installed and
|
||||
used to get your website up and running quickly.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Focus on What Matters",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Docusaurus lets you focus on your docs, and we'll do the chores. Go
|
||||
ahead and move your docs into the <code>docs</code> directory.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Powered by React",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Extend or customize your website layout by reusing React. Docusaurus can
|
||||
be extended while reusing the same header and footer.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({ title, Svg, description }: FeatureItem) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures(): JSX.Element {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
11
dev-docs/src/components/Homepage/styles.module.css
Normal file
11
dev-docs/src/components/Homepage/styles.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
43
dev-docs/src/css/custom.css
Normal file
43
dev-docs/src/css/custom.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #6965db;
|
||||
--ifm-color-primary-dark: #5b57d1;
|
||||
--ifm-color-primary-darker: #5b57d1;
|
||||
--ifm-color-primary-darkest: #4a47b1;
|
||||
--ifm-color-primary-light: #5b57d1;
|
||||
--ifm-color-primary-lighter: #5b57d1;
|
||||
--ifm-color-primary-lightest: #5b57d1;
|
||||
--ifm-code-font-size: 95%;
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #5650f0;
|
||||
--ifm-color-primary-dark: #4b46d8;
|
||||
--ifm-color-primary-darker: #4b46d8;
|
||||
--ifm-color-primary-darkest: #3e39be;
|
||||
--ifm-color-primary-light: #3f3d64;
|
||||
--ifm-color-primary-lighter: #3f3d64;
|
||||
--ifm-color-primary-lightest: #3f3d64;
|
||||
}
|
||||
|
||||
.docusaurus-highlight-code-line {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||
padding: 0 var(--ifm-pre-padding);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .docusaurus-highlight-code-line {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .navbar__logo {
|
||||
filter: invert(93%) hue-rotate(180deg);
|
||||
}
|
42
dev-docs/src/pages/index.js
Normal file
42
dev-docs/src/pages/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import Layout from "@theme/Layout";
|
||||
import Link from "@docusaurus/Link";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import styles from "./index.module.css";
|
||||
import HomepageFeatures from "@site/src/components/Homepage";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link
|
||||
className="button button--secondary button--lg"
|
||||
to="/docs/get-started"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`Hello from ${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
27
dev-docs/src/pages/index.module.css
Normal file
27
dev-docs/src/pages/index.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.heroBanner {
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .heroBanner {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
42
dev-docs/src/pages/index.tsx
Normal file
42
dev-docs/src/pages/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import Layout from "@theme/Layout";
|
||||
import Link from "@docusaurus/Link";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import styles from "./index.module.css";
|
||||
import HomepageFeatures from "@site/src/components/Homepage";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link
|
||||
className="button button--secondary button--lg"
|
||||
to="/docs/get-started"
|
||||
>
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`Hello from ${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
7
dev-docs/src/pages/markdown-page.md
Normal file
7
dev-docs/src/pages/markdown-page.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Markdown page example
|
||||
---
|
||||
|
||||
# Markdown page example
|
||||
|
||||
You don't need React to write simple standalone pages.
|
0
dev-docs/static/.nojekyll
Normal file
0
dev-docs/static/.nojekyll
Normal file
BIN
dev-docs/static/img/docusaurus.png
Normal file
BIN
dev-docs/static/img/docusaurus.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.0 KiB |
BIN
dev-docs/static/img/favicon.ico
Normal file
BIN
dev-docs/static/img/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
4
dev-docs/static/img/logo.svg
Normal file
4
dev-docs/static/img/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 80 180" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
|
||||
<path d="M22.197 150.382c-4.179-3.359-10.618-9.051-15.702-13.946l-4.01-3.813.734-5.009c.396-2.732 1.13-8.083 1.582-11.839.508-3.757 1.017-7.286 1.186-7.798.226-.683 0-1.025-.621-1.025-1.073 0-1.13.285 1.807-9.107a617.602 617.602 0 0 1 2.203-7.229c.113-.398.565-.569 1.073-.398.508.227.791.683.621 1.081-.169.455.113.911.565 1.082.621.227.565.683-.395 2.333-1.525 2.562-5.422 24.419-5.648 31.477-.17 5.009-.17 5.066 1.92 7.912 2.033 2.789 6.721 7.001 13.951 12.351 2.033 1.537 4.067 3.245 4.631 3.814.848 1.024 1.243.74 8.36-6.887 4.123-4.383 8.698-8.88 10.166-10.018l2.711-2.049-2.089-4.44c-1.13-2.391-5.705-11.612-10.223-20.377-9.433-18.442-7.513-16.678-18.47-16.849l-7.117-.056-2.372-2.733c-2.485-2.903-2.824-3.984-1.638-5.805.452-.627.791-1.651.791-2.277 0-1.025.395-1.196 2.655-1.309 1.412-.057 2.711-.228 2.88-.399.17-.171.396-3.7.565-7.855l.226-7.513-3.784-8.197C2.485 39.844 0 33.583 0 31.533c0-1.081.226-1.992.452-1.992.565 0 .565.057 23.553 48.382 10.675 22.426 20.785 43.544 22.479 47.016 1.695 3.472 3.22 6.659 3.333 7.115.113.512-3.785 4.439-9.998 9.961-5.591 5.008-10.505 9.562-10.957 10.074-1.299 1.594-3.219 1.082-6.665-1.707Zm1.921-65.458c-2.599-5.066-2.712-5.123-9.828-5.464-6.27-.342-6.383-.285-6.383.911 0 .683-.226 1.593-.508 2.049-.339.512-.113 1.423.678 2.675l1.242 1.935h5.649c3.106.057 6.664.285 7.907.512 1.243.228 2.316.342 2.429.285.113-.057-.452-1.366-1.186-2.903Zm-4.745-9.107c-.452-1.195-1.638-3.7-2.598-5.578-1.581-3.188-1.751-3.301-2.146-1.992-.226.797-.396 3.13-.452 5.236-.057 4.155-.17 4.098 4.575 4.383l1.525.057-.904-2.106Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||
<path d="M23.892 136.835c-1.017-.74-1.299-1.48-1.299-3.358 0-2.22.169-2.562 1.694-3.188 1.525-.626 1.92-.569 3.671.626 2.316 1.594 2.373 1.992.678 4.554-1.468 2.22-2.937 2.618-4.744 1.366Zm3.219-2.049c.904-1.594.339-2.789-1.355-2.789-1.525 0-2.203 1.536-1.356 3.073.678 1.253 1.977 1.139 2.711-.284ZM59.306 124.028c0-.285-.339-.569-.735-.569-.339 0-1.299-1.594-2.033-3.529-2.259-5.92-24.852-50.943-24.908-49.52 0 .74-.339 1.252-.904 1.252-.791 0-.904-.456-.565-2.675.339-2.562.113-3.131-7.907-18.841-4.519-8.936-9.376-18.271-10.788-20.775-1.469-2.619-2.598-5.465-2.711-6.66-.17-2.049.056-2.334 4.97-6.603 2.824-2.504 6.439-5.635 8.02-7.058C28.862 2.504 32.194-.114 33.098.057c1.356.228 22.31 22.369 22.367 23.622 0 .569-1.017 9.221-2.259 19.238-2.147 17.076-4.18 37.055-3.954 38.99.169 1.196-.678 7.229-1.299 9.847-.509 2.05-.283 2.903 3.784 12.238 2.372 5.521 5.479 12.295 6.834 15.027 1.299 2.732 2.429 5.123 2.429 5.294 0 .17-.395.284-.847.284-.452 0-.847-.228-.847-.569ZM46.315 81.509c.621-3.984 1.864-13.547 2.767-21.231 1.751-14.116 3.785-29.769 4.349-33.753.339-1.993.113-2.391-3.558-6.489-6.382-7.229-13.16-14.344-15.476-16.165l-2.146-1.708-11.014 10.359C11.07 21.971 10.223 22.939 10.844 24.077c.339.626 3.22 5.92 6.383 11.725 3.163 5.806 7.342 13.547 9.263 17.19 1.977 3.7 3.784 6.887 4.123 7.058.395.228.508-5.521.395-17.759-.226-18.271-.169-18.328 1.638-17.929.226 0 .396 9.221.396 20.434v20.377l5.93 11.953c3.276 6.603 5.987 11.896 6.1 11.84.113-.058.678-3.416 1.243-7.457Z" style="fill-rule:nonzero;stroke:#000;stroke-width:2px" transform="matrix(1.01351 0 0 -1 9.088 166.517)" />
|
||||
</svg>
|
After Width: | Height: | Size: 3.4 KiB |
1
dev-docs/static/img/undraw_add_files.svg
Normal file
1
dev-docs/static/img/undraw_add_files.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.7 KiB |
1
dev-docs/static/img/undraw_blank_canvas.svg
Normal file
1
dev-docs/static/img/undraw_blank_canvas.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 12 KiB |
1
dev-docs/static/img/undraw_innovative.svg
Normal file
1
dev-docs/static/img/undraw_innovative.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 5.4 KiB |
7
dev-docs/tsconfig.json
Normal file
7
dev-docs/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@tsconfig/docusaurus/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
7489
dev-docs/yarn.lock
Normal file
7489
dev-docs/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,8 +26,8 @@
|
||||
"@tldraw/vec": "1.7.1",
|
||||
"@types/jest": "27.4.0",
|
||||
"@types/pica": "5.1.3",
|
||||
"@types/react": "17.0.39",
|
||||
"@types/react-dom": "17.0.11",
|
||||
"@types/react": "18.0.15",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"clsx": "1.1.1",
|
||||
@@ -47,8 +47,8 @@
|
||||
"png-chunks-extract": "1.0.0",
|
||||
"points-on-curve": "0.2.0",
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
|
21
scripts/buildDocs.js
Normal file
21
scripts/buildDocs.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const { exec } = require("child_process");
|
||||
|
||||
// get files changed between prev and head commit
|
||||
exec(`git diff --name-only HEAD^ HEAD`, async (error, stdout, stderr) => {
|
||||
if (error || stderr) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
const changedFiles = stdout.trim().split("\n");
|
||||
|
||||
const docFiles = changedFiles.filter((file) => {
|
||||
return file.indexOf("docs") >= 0;
|
||||
});
|
||||
|
||||
if (!docFiles.length) {
|
||||
console.info("Skipping building docs as no valid diff found");
|
||||
process.exit(0);
|
||||
}
|
||||
// Exit code 1 to build the docs in ignoredBuildStep
|
||||
process.exit(1);
|
||||
});
|
@@ -1,11 +1,12 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// for development purposes we want to have the service-worker.js file
|
||||
// accessible from the public folder. On build though, we need to compile it
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = "./public/service-worker.js";
|
||||
const newPath = "./src/service-worker.js";
|
||||
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
|
@@ -244,7 +244,7 @@ export const actionLoadScene = register({
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={load}
|
||||
|
@@ -13,7 +13,7 @@ import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
} from "../element/binding";
|
||||
import { isBindingElement } from "../element/typeChecks";
|
||||
import { isBindingElement, isLinearElement } from "../element/typeChecks";
|
||||
import { AppState } from "../types";
|
||||
|
||||
export const actionFinalize = register({
|
||||
@@ -181,6 +181,11 @@ export const actionFinalize = register({
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(multiPointElement, scene)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
commitToHistory: appState.activeTool.type === "freedraw",
|
||||
|
@@ -3,32 +3,42 @@ import { register } from "./register";
|
||||
import { selectGroupsForSelectedElements } from "../groups";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const selectedElementIds = elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
{
|
||||
...appState,
|
||||
selectedLinearElement:
|
||||
// single linear element selected
|
||||
Object.keys(selectedElementIds).length === 1 &&
|
||||
isLinearElement(elements[0])
|
||||
? new LinearElementEditor(elements[0], app.scene)
|
||||
: null,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
),
|
||||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
),
|
||||
|
@@ -17,16 +17,19 @@ export const actionToggleLock = register({
|
||||
|
||||
const operation = getOperation(selectedElements);
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
|
||||
const lock = operation === "lock";
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!selectedElementsMap.has(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, { locked: operation === "lock" });
|
||||
return newElementWith(element, { locked: lock });
|
||||
}),
|
||||
appState,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedLinearElement: lock ? null : appState.selectedLinearElement,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@@ -147,6 +147,7 @@ export class ActionManager {
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
PanelComponent.displayName = "PanelComponent";
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
const updateData = (formState?: any) => {
|
||||
|
@@ -90,6 +90,7 @@ export const getDefaultAppState = (): Omit<
|
||||
viewModeEnabled: false,
|
||||
pendingImageElementId: null,
|
||||
showHyperlinkPopup: false,
|
||||
selectedLinearElement: null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -181,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
viewModeEnabled: { browser: false, export: false, server: false },
|
||||
pendingImageElementId: { browser: false, export: false, server: false },
|
||||
showHyperlinkPopup: { browser: false, export: false, server: false },
|
||||
selectedLinearElement: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
@@ -26,17 +26,17 @@ import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
activeTool,
|
||||
}: {
|
||||
appState: AppState;
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
activeTool: AppState["activeTool"]["type"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
@@ -56,13 +56,13 @@ export const SelectedShapeActions = ({
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const showFillIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
const showChangeBackgroundIcons =
|
||||
hasBackground(activeTool) ||
|
||||
hasBackground(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasBackground(element.type));
|
||||
|
||||
const showLinkIcon =
|
||||
@@ -79,23 +79,23 @@ export const SelectedShapeActions = ({
|
||||
|
||||
return (
|
||||
<div className="panelColumn">
|
||||
{((hasStrokeColor(activeTool) &&
|
||||
activeTool !== "image" &&
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
{showChangeBackgroundIcons && renderAction("changeBackgroundColor")}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(activeTool) ||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type))) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
|
||||
{(activeTool === "freedraw" ||
|
||||
{(appState.activeTool.type === "freedraw" ||
|
||||
targetElements.some((element) => element.type === "freedraw")) &&
|
||||
renderAction("changeStrokeShape")}
|
||||
|
||||
{(hasStrokeStyle(activeTool) ||
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
@@ -103,12 +103,12 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeSharpness(activeTool) ||
|
||||
{(canChangeSharpness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(activeTool) ||
|
||||
{(hasText(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasText(element.type))) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
@@ -123,7 +123,7 @@ export const SelectedShapeActions = ({
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(activeTool) ||
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
)}
|
||||
@@ -271,3 +271,45 @@ export const ZoomActions = ({
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
||||
export const UndoRedoActions = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`undo-redo-buttons ${className}`}>
|
||||
{renderAction("undo", { size: "small" })}
|
||||
{renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ExitZenModeAction = ({
|
||||
executeAction,
|
||||
showExitZenModeBtn,
|
||||
}: {
|
||||
executeAction: ActionManager["executeAction"];
|
||||
showExitZenModeBtn: boolean;
|
||||
}) => (
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={() => executeAction(actionToggleZenMode)}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const FinalizeAction = ({
|
||||
renderAction,
|
||||
className,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={`finalize-button ${className}`}>
|
||||
{renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
);
|
||||
|
@@ -1,4 +1,6 @@
|
||||
import React, { useContext } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import clsx from "clsx";
|
||||
@@ -86,9 +88,9 @@ import {
|
||||
getDragOffsetXY,
|
||||
getElementWithTransformHandleType,
|
||||
getNormalizedDimensions,
|
||||
getPerfectElementSize,
|
||||
getResizeArrowDirection,
|
||||
getResizeOffsetXY,
|
||||
getLockedLinearCursorAlignSize,
|
||||
getTransformHandleTypeFromCoords,
|
||||
hitTest,
|
||||
isHittingElementBoundingBoxWithoutHittingElement,
|
||||
@@ -104,6 +106,7 @@ import {
|
||||
updateTextElement,
|
||||
} from "../element";
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
bindOrUnbindSelectedElements,
|
||||
fixBindingsAfterDeletion,
|
||||
fixBindingsAfterDuplication,
|
||||
@@ -259,6 +262,7 @@ import {
|
||||
isPointHittingLinkIcon,
|
||||
isLocalLink,
|
||||
} from "../element/Hyperlink";
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
|
||||
const deviceContextInitialValue = {
|
||||
isSmScreen: false,
|
||||
@@ -268,6 +272,7 @@ const deviceContextInitialValue = {
|
||||
};
|
||||
const DeviceContext = React.createContext<Device>(deviceContextInitialValue);
|
||||
export const useDevice = () => useContext<Device>(DeviceContext);
|
||||
|
||||
const ExcalidrawContainerContext = React.createContext<{
|
||||
container: HTMLDivElement | null;
|
||||
id: string | null;
|
||||
@@ -275,6 +280,22 @@ const ExcalidrawContainerContext = React.createContext<{
|
||||
export const useExcalidrawContainer = () =>
|
||||
useContext(ExcalidrawContainerContext);
|
||||
|
||||
const ExcalidrawElementsContext = React.createContext<
|
||||
readonly NonDeletedExcalidrawElement[]
|
||||
>([]);
|
||||
|
||||
const ExcalidrawAppStateContext = React.createContext<AppState>({
|
||||
...getDefaultAppState(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
offsetLeft: 0,
|
||||
offsetTop: 0,
|
||||
});
|
||||
export const useExcalidrawElements = () =>
|
||||
useContext(ExcalidrawElementsContext);
|
||||
export const useExcalidrawAppState = () =>
|
||||
useContext(ExcalidrawAppStateContext);
|
||||
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
let cursorX = 0;
|
||||
@@ -501,63 +522,69 @@ class App extends React.Component<AppProps, AppState> {
|
||||
value={this.excalidrawContainerValue}
|
||||
>
|
||||
<DeviceContext.Provider value={this.device}>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomFooter={renderFooter}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
showThemeBtn={
|
||||
typeof this.props?.theme === "undefined" &&
|
||||
this.props.UIOptions.canvasActions.theme
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 && this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
appState={this.state}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
/>
|
||||
)}
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
<ExcalidrawAppStateContext.Provider value={this.state}>
|
||||
<ExcalidrawElementsContext.Provider
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
setAppState={this.setAppState}
|
||||
actionManager={this.actionManager}
|
||||
elements={this.scene.getNonDeletedElements()}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={this.toggleLock}
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onInsertElements={(elements) =>
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
position: "center",
|
||||
files: null,
|
||||
})
|
||||
}
|
||||
langCode={getLanguage().code}
|
||||
isCollaborating={this.props.isCollaborating}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomFooter={renderFooter}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
typeof this.props?.zenModeEnabled === "undefined" &&
|
||||
this.state.zenModeEnabled
|
||||
}
|
||||
showThemeBtn={
|
||||
typeof this.props?.theme === "undefined" &&
|
||||
this.props.UIOptions.canvasActions.theme
|
||||
}
|
||||
libraryReturnUrl={this.props.libraryReturnUrl}
|
||||
UIOptions={this.props.UIOptions}
|
||||
focusContainer={this.focusContainer}
|
||||
library={this.library}
|
||||
id={this.id}
|
||||
onImageAction={this.onImageAction}
|
||||
/>
|
||||
<div className="excalidraw-textEditorContainer" />
|
||||
<div className="excalidraw-contextMenuContainer" />
|
||||
{selectedElement.length === 1 &&
|
||||
this.state.showHyperlinkPopup && (
|
||||
<Hyperlink
|
||||
key={selectedElement[0].id}
|
||||
element={selectedElement[0]}
|
||||
setAppState={this.setAppState}
|
||||
onLinkOpen={this.props.onLinkOpen}
|
||||
/>
|
||||
)}
|
||||
{this.state.toast !== null && (
|
||||
<Toast
|
||||
message={this.state.toast.message}
|
||||
onClose={() => this.setToast(null)}
|
||||
duration={this.state.toast.duration}
|
||||
closable={this.state.toast.closable}
|
||||
/>
|
||||
)}
|
||||
<main>{this.renderCanvas()}</main>
|
||||
</ExcalidrawElementsContext.Provider>{" "}
|
||||
</ExcalidrawAppStateContext.Provider>
|
||||
</DeviceContext.Provider>
|
||||
</ExcalidrawContainerContext.Provider>
|
||||
</div>
|
||||
@@ -908,7 +935,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
} else {
|
||||
this.updateDOMRect(this.initializeScene);
|
||||
}
|
||||
this.checkIfBrowserZoomed();
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
@@ -921,25 +947,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
clearTimeout(touchTimeout);
|
||||
touchTimeout = 0;
|
||||
}
|
||||
private checkIfBrowserZoomed = () => {
|
||||
if (!this.device.isMobile) {
|
||||
const scrollBarWidth = 10;
|
||||
const widthRatio =
|
||||
(window.outerWidth - scrollBarWidth) / window.innerWidth;
|
||||
const isBrowserZoomed = widthRatio < 0.75 || widthRatio > 1.1;
|
||||
if (isBrowserZoomed) {
|
||||
this.setToast({
|
||||
message: t("alerts.browserZoom"),
|
||||
closable: true,
|
||||
duration: Infinity,
|
||||
});
|
||||
} else {
|
||||
this.setToast(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onResize = withBatchedUpdates(() => {
|
||||
this.checkIfBrowserZoomed();
|
||||
this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.forEach((element) => invalidateShapeForElement(element));
|
||||
@@ -1150,6 +1159,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement &&
|
||||
!this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
|
||||
) {
|
||||
// To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once
|
||||
// we have a single API to update `selectedElementIds`
|
||||
this.setState({ selectedLinearElement: null });
|
||||
}
|
||||
|
||||
const { multiElement } = prevState;
|
||||
if (
|
||||
prevState.activeTool !== this.state.activeTool &&
|
||||
@@ -1169,7 +1188,23 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
);
|
||||
}
|
||||
this.renderScene();
|
||||
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
// Do not notify consumers if we're still loading the scene. Among other
|
||||
// potential issues, this fixes a case where the tab isn't focused during
|
||||
// init, which would trigger onChange with empty elements, which would then
|
||||
// override whatever is in localStorage currently.
|
||||
if (!this.state.isLoading) {
|
||||
this.props.onChange?.(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
this.files,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private renderScene = () => {
|
||||
const cursorButton: {
|
||||
[id: string]: string | undefined;
|
||||
} = {};
|
||||
@@ -1206,6 +1241,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
});
|
||||
|
||||
const renderingElements = this.scene
|
||||
.getNonDeletedElements()
|
||||
.filter((element) => {
|
||||
@@ -1227,42 +1263,43 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
renderScene(
|
||||
renderingElements,
|
||||
this.state,
|
||||
this.state.selectionElement,
|
||||
window.devicePixelRatio,
|
||||
this.rc!,
|
||||
this.canvas!,
|
||||
{
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
zoom: this.state.zoom,
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||
theme: this.state.theme,
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderScrollbars: !this.device.isMobile,
|
||||
},
|
||||
({ atLeastOneVisibleElement, scrollBars }) => {
|
||||
if (scrollBars) {
|
||||
currentScrollBars = scrollBars;
|
||||
}
|
||||
const scrolledOutside =
|
||||
// hide when editing text
|
||||
isTextElement(this.state.editingElement)
|
||||
? false
|
||||
: !atLeastOneVisibleElement && renderingElements.length > 0;
|
||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||
this.setState({ scrolledOutside });
|
||||
}
|
||||
elements: renderingElements,
|
||||
appState: this.state,
|
||||
scale: window.devicePixelRatio,
|
||||
rc: this.rc!,
|
||||
canvas: this.canvas!,
|
||||
renderConfig: {
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor,
|
||||
zoom: this.state.zoom,
|
||||
remotePointerViewportCoords: pointerViewportCoords,
|
||||
remotePointerButton: cursorButton,
|
||||
remoteSelectedElementIds,
|
||||
remotePointerUsernames: pointerUsernames,
|
||||
remotePointerUserStates: pointerUserStates,
|
||||
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
|
||||
theme: this.state.theme,
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderScrollbars: !this.device.isMobile,
|
||||
},
|
||||
callback: ({ atLeastOneVisibleElement, scrollBars }) => {
|
||||
if (scrollBars) {
|
||||
currentScrollBars = scrollBars;
|
||||
}
|
||||
const scrolledOutside =
|
||||
// hide when editing text
|
||||
isTextElement(this.state.editingElement)
|
||||
? false
|
||||
: !atLeastOneVisibleElement && renderingElements.length > 0;
|
||||
if (this.state.scrolledOutside !== scrolledOutside) {
|
||||
this.setState({ scrolledOutside });
|
||||
}
|
||||
|
||||
this.scheduleImageRefresh();
|
||||
this.scheduleImageRefresh();
|
||||
},
|
||||
},
|
||||
THROTTLE_NEXT_RENDER && window.EXCALIDRAW_THROTTLE_RENDER === true,
|
||||
);
|
||||
@@ -1270,21 +1307,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!THROTTLE_NEXT_RENDER) {
|
||||
THROTTLE_NEXT_RENDER = true;
|
||||
}
|
||||
|
||||
this.history.record(this.state, this.scene.getElementsIncludingDeleted());
|
||||
|
||||
// Do not notify consumers if we're still loading the scene. Among other
|
||||
// potential issues, this fixes a case where the tab isn't focused during
|
||||
// init, which would trigger onChange with empty elements, which would then
|
||||
// override whatever is in localStorage currently.
|
||||
if (!this.state.isLoading) {
|
||||
this.props.onChange?.(
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
this.state,
|
||||
this.files,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onScroll = debounce(() => {
|
||||
const { offsetTop, offsetLeft } = this.getCanvasOffsets();
|
||||
@@ -1872,35 +1895,32 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (
|
||||
selectedElements.length === 1 &&
|
||||
isLinearElement(selectedElements[0])
|
||||
) {
|
||||
if (
|
||||
!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
selectedElements.length === 1 &&
|
||||
!isLinearElement(selectedElements[0])
|
||||
) {
|
||||
const selectedElement = selectedElements[0];
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (
|
||||
!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElements[0],
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const selectedElement = selectedElements[0];
|
||||
|
||||
this.startTextEditing({
|
||||
sceneX: selectedElement.x + selectedElement.width / 2,
|
||||
sceneY: selectedElement.y + selectedElement.height / 2,
|
||||
shouldBind: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
return;
|
||||
this.startTextEditing({
|
||||
sceneX: selectedElement.x + selectedElement.width / 2,
|
||||
sceneY: selectedElement.y + selectedElement.height / 2,
|
||||
shouldBind: true,
|
||||
});
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
!event.ctrlKey &&
|
||||
@@ -2702,7 +2722,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.state.gridSize,
|
||||
);
|
||||
if (editingLinearElement !== this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement });
|
||||
// Since we are reading from previous state which is not possible with
|
||||
// automatic batching in React 18 hence using flush sync to synchronously
|
||||
// update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
|
||||
flushSync(() => {
|
||||
this.setState({ editingLinearElement });
|
||||
});
|
||||
}
|
||||
if (editingLinearElement.lastUncommittedPoint != null) {
|
||||
this.maybeSuggestBindingAtCursor(scenePointer);
|
||||
@@ -2769,6 +2794,30 @@ class App extends React.Component<AppProps, AppState> {
|
||||
points: points.slice(0, -1),
|
||||
});
|
||||
} else {
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
this.state.gridSize,
|
||||
);
|
||||
|
||||
const [lastCommittedX, lastCommittedY] =
|
||||
multiElement?.lastCommittedPoint ?? [0, 0];
|
||||
|
||||
let dxFromLastCommitted = gridX - rx - lastCommittedX;
|
||||
let dyFromLastCommitted = gridY - ry - lastCommittedY;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event)) {
|
||||
({ width: dxFromLastCommitted, height: dyFromLastCommitted } =
|
||||
getLockedLinearCursorAlignSize(
|
||||
// actual coordinate of the last committed point
|
||||
lastCommittedX + rx,
|
||||
lastCommittedY + ry,
|
||||
// cursor-grid coordinate
|
||||
gridX,
|
||||
gridY,
|
||||
));
|
||||
}
|
||||
|
||||
if (isPathALoop(points, this.state.zoom.value)) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.POINTER);
|
||||
}
|
||||
@@ -2776,7 +2825,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
mutateElement(multiElement, {
|
||||
points: [
|
||||
...points.slice(0, -1),
|
||||
[scenePointerX - rx, scenePointerY - ry],
|
||||
[
|
||||
lastCommittedX + dxFromLastCommitted,
|
||||
lastCommittedY + dyFromLastCommitted,
|
||||
],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -2875,22 +2927,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
setCursor(this.canvas, CURSOR_TYPE.GRAB);
|
||||
} else if (isOverScrollBar) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
} else if (this.state.editingLinearElement) {
|
||||
const element = LinearElementEditor.getElement(
|
||||
this.state.editingLinearElement.elementId,
|
||||
} else if (this.state.selectedLinearElement) {
|
||||
this.handleHoverSelectedLinearElement(
|
||||
this.state.selectedLinearElement,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
);
|
||||
|
||||
if (
|
||||
element &&
|
||||
isHittingElementNotConsideringBoundingBox(element, this.state, [
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
])
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
} else {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
}
|
||||
} else if (
|
||||
// if using cmd/ctrl, we're not dragging
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
@@ -3002,6 +3044,81 @@ class App extends React.Component<AppProps, AppState> {
|
||||
invalidateContextMenu = true;
|
||||
};
|
||||
|
||||
handleHoverSelectedLinearElement(
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
) {
|
||||
const element = LinearElementEditor.getElement(
|
||||
linearElementEditor.elementId,
|
||||
);
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (this.state.selectedLinearElement) {
|
||||
let hoverPointIndex = -1;
|
||||
let midPointHovered = false;
|
||||
if (
|
||||
isHittingElementNotConsideringBoundingBox(element, this.state, [
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
])
|
||||
) {
|
||||
hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
this.state.zoom,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
);
|
||||
midPointHovered = LinearElementEditor.isHittingMidPoint(
|
||||
linearElementEditor,
|
||||
{ x: scenePointerX, y: scenePointerY },
|
||||
this.state,
|
||||
);
|
||||
|
||||
if (hoverPointIndex >= 0 || midPointHovered) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.POINTER);
|
||||
} else {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
} else if (
|
||||
shouldShowBoundingBox([element], this.state) &&
|
||||
isHittingElementBoundingBoxWithoutHittingElement(
|
||||
element,
|
||||
this.state,
|
||||
scenePointerX,
|
||||
scenePointerY,
|
||||
)
|
||||
) {
|
||||
setCursor(this.canvas, CURSOR_TYPE.MOVE);
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement.hoverPointIndex !== hoverPointIndex
|
||||
) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
hoverPointIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
this.state.selectedLinearElement.midPointHovered !== midPointHovered
|
||||
) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
midPointHovered,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setCursor(this.canvas, CURSOR_TYPE.AUTO);
|
||||
}
|
||||
}
|
||||
private handleCanvasPointerDown = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
@@ -3398,7 +3515,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
origin,
|
||||
selectedElements,
|
||||
),
|
||||
hasHitElementInside: false,
|
||||
},
|
||||
drag: {
|
||||
hasOccurred: false,
|
||||
@@ -3532,18 +3648,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.state.editingLinearElement) {
|
||||
if (this.state.selectedLinearElement) {
|
||||
const linearElementEditor =
|
||||
this.state.editingLinearElement || this.state.selectedLinearElement;
|
||||
const ret = LinearElementEditor.handlePointerDown(
|
||||
event,
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
this.history,
|
||||
pointerDownState.origin,
|
||||
linearElementEditor,
|
||||
);
|
||||
if (ret.hitElement) {
|
||||
pointerDownState.hit.element = ret.hitElement;
|
||||
}
|
||||
if (ret.didAddPoint) {
|
||||
if (ret.linearElementEditor) {
|
||||
this.setState({ selectedLinearElement: ret.linearElementEditor });
|
||||
|
||||
if (this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement: ret.linearElementEditor });
|
||||
}
|
||||
}
|
||||
if (ret.didAddPoint && !ret.isMidPoint) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3557,22 +3682,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (pointerDownState.hit.element) {
|
||||
// Early return if pointer is hitting link icon
|
||||
if (
|
||||
isPointHittingLinkIcon(
|
||||
pointerDownState.hit.element,
|
||||
this.state,
|
||||
[pointerDownState.origin.x, pointerDownState.origin.y],
|
||||
this.device.isMobile,
|
||||
)
|
||||
) {
|
||||
const hitLinkElement = this.getElementLinkAtPosition(
|
||||
{
|
||||
x: pointerDownState.origin.x,
|
||||
y: pointerDownState.origin.y,
|
||||
},
|
||||
pointerDownState.hit.element,
|
||||
);
|
||||
if (hitLinkElement) {
|
||||
return false;
|
||||
}
|
||||
pointerDownState.hit.hasHitElementInside =
|
||||
isHittingElementNotConsideringBoundingBox(
|
||||
pointerDownState.hit.element,
|
||||
this.state,
|
||||
[pointerDownState.origin.x, pointerDownState.origin.y],
|
||||
);
|
||||
}
|
||||
|
||||
// For overlapped elements one position may hit
|
||||
@@ -4032,6 +4151,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// to ensure we don't create a 2-point arrow by mistake when
|
||||
// user clicks mouse in a way that it moves a tiny bit (thus
|
||||
// triggering pointermove)
|
||||
|
||||
if (
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
(this.state.activeTool.type === "arrow" ||
|
||||
@@ -4048,7 +4168,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pointerDownState.resize.isResizing) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
@@ -4057,10 +4176,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.state.editingLinearElement) {
|
||||
if (this.state.selectedLinearElement) {
|
||||
const linearElementEditor =
|
||||
this.state.editingLinearElement || this.state.selectedLinearElement;
|
||||
const didDrag = LinearElementEditor.handlePointDragging(
|
||||
event,
|
||||
this.state,
|
||||
(appState) => this.setState(appState),
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
(element, pointsSceneCoords) => {
|
||||
@@ -4069,11 +4190,31 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointsSceneCoords,
|
||||
);
|
||||
},
|
||||
linearElementEditor,
|
||||
);
|
||||
|
||||
if (didDrag) {
|
||||
pointerDownState.lastCoords.x = pointerCoords.x;
|
||||
pointerDownState.lastCoords.y = pointerCoords.y;
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
if (
|
||||
this.state.editingLinearElement &&
|
||||
!this.state.editingLinearElement.isDragging
|
||||
) {
|
||||
this.setState({
|
||||
editingLinearElement: {
|
||||
...this.state.editingLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!this.state.selectedLinearElement.isDragging) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...this.state.selectedLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4082,17 +4223,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(element) => this.isASelectedElement(element),
|
||||
);
|
||||
|
||||
const isSelectingPointsInLineEditor =
|
||||
this.state.editingLinearElement &&
|
||||
event.shiftKey &&
|
||||
this.state.editingLinearElement.elementId ===
|
||||
pointerDownState.hit.element?.id;
|
||||
if (
|
||||
(hasHitASelectedElement ||
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) &&
|
||||
// this allows for box-selecting points when clicking inside the
|
||||
// line's bounding box
|
||||
(!this.state.editingLinearElement || !event.shiftKey) &&
|
||||
// box-selecting without shift when editing line, not clicking on a line
|
||||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement?.elementId !==
|
||||
pointerDownState.hit.element?.id ||
|
||||
pointerDownState.hit.hasHitElementInside)
|
||||
!isSelectingPointsInLineEditor
|
||||
) {
|
||||
const selectedElements = getSelectedElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
@@ -4120,7 +4259,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// We only drag in one direction if shift is pressed
|
||||
const lockDirection = event.shiftKey;
|
||||
|
||||
dragSelectedElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
@@ -4232,16 +4370,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
let dy = gridY - draggingElement.y;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length === 2) {
|
||||
({ width: dx, height: dy } = getPerfectElementSize(
|
||||
this.state.activeTool.type,
|
||||
dx,
|
||||
dy,
|
||||
({ width: dx, height: dy } = getLockedLinearCursorAlignSize(
|
||||
draggingElement.x,
|
||||
draggingElement.y,
|
||||
pointerCoords.x,
|
||||
pointerCoords.y,
|
||||
));
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
mutateElement(draggingElement, { points: [...points, [dx, dy]] });
|
||||
} else if (points.length > 1) {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points, [dx, dy]],
|
||||
});
|
||||
} else if (points.length === 2) {
|
||||
mutateElement(draggingElement, {
|
||||
points: [...points.slice(0, -1), [dx, dy]],
|
||||
});
|
||||
@@ -4331,6 +4472,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsWithinSelection[0].link
|
||||
? "info"
|
||||
: false,
|
||||
// select linear element only when we haven't box-selected anything else
|
||||
selectedLinearElement:
|
||||
elementsWithinSelection.length === 1 &&
|
||||
isLinearElement(elementsWithinSelection[0])
|
||||
? new LinearElementEditor(
|
||||
elementsWithinSelection[0],
|
||||
this.scene,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
),
|
||||
@@ -4400,9 +4550,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (this.state.editingLinearElement) {
|
||||
if (
|
||||
!pointerDownState.boxSelection.hasOccurred &&
|
||||
(pointerDownState.hit?.element?.id !==
|
||||
this.state.editingLinearElement.elementId ||
|
||||
!pointerDownState.hit.hasHitElementInside)
|
||||
pointerDownState.hit?.element?.id !==
|
||||
this.state.editingLinearElement.elementId
|
||||
) {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
} else {
|
||||
@@ -4418,6 +4567,47 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (this.state.selectedLinearElement) {
|
||||
if (
|
||||
pointerDownState.hit?.element?.id !==
|
||||
this.state.selectedLinearElement.elementId
|
||||
) {
|
||||
const selectedELements = getSelectedElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
);
|
||||
// set selectedLinearElement to null if there is more than one element selected since we don't want to show linear element handles
|
||||
if (selectedELements.length > 1) {
|
||||
this.setState({ selectedLinearElement: null });
|
||||
}
|
||||
} else {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
childEvent,
|
||||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } =
|
||||
linearElementEditor;
|
||||
const element = this.scene.getElement(linearElementEditor.elementId);
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
);
|
||||
}
|
||||
|
||||
if (linearElementEditor !== this.state.selectedLinearElement) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
},
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastPointerUp = null;
|
||||
@@ -4550,6 +4740,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...prevState.selectedElementIds,
|
||||
[draggingElement.id]: true,
|
||||
},
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
draggingElement,
|
||||
this.scene,
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
this.setState((prevState) => ({
|
||||
@@ -4601,6 +4795,25 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// Code below handles selection when element(s) weren't
|
||||
// drag or added to selection on pointer down phase.
|
||||
const hitElement = pointerDownState.hit.element;
|
||||
if (
|
||||
this.state.selectedLinearElement?.elementId !== hitElement?.id &&
|
||||
isLinearElement(hitElement)
|
||||
) {
|
||||
const selectedELements = getSelectedElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
this.state,
|
||||
);
|
||||
// set selectedLinearElement when no other element selected except
|
||||
// the one we've hit
|
||||
if (selectedELements.length === 1) {
|
||||
this.setState({
|
||||
selectedLinearElement: new LinearElementEditor(
|
||||
hitElement,
|
||||
this.scene,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isEraserActive(this.state)) {
|
||||
const draggedDistance = distance2d(
|
||||
this.lastPointerDown!.clientX,
|
||||
@@ -4673,26 +4886,42 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...idsOfSelectedElementsThatAreInGroups,
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
// if not gragging a linear element point (outside editor)
|
||||
} else if (!this.state.selectedLinearElement?.isDragging) {
|
||||
// remove element from selection while
|
||||
// keeping prev elements selected
|
||||
this.setState((prevState) =>
|
||||
selectGroupsForSelectedElements(
|
||||
|
||||
this.setState((prevState) => {
|
||||
const newSelectedElementIds = {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: false,
|
||||
};
|
||||
const newSelectedElements = getSelectedElements(
|
||||
this.scene.getNonDeletedElements(),
|
||||
{ ...prevState, selectedElementIds: newSelectedElementIds },
|
||||
);
|
||||
|
||||
return selectGroupsForSelectedElements(
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: {
|
||||
...prevState.selectedElementIds,
|
||||
[hitElement!.id]: false,
|
||||
},
|
||||
selectedElementIds: newSelectedElementIds,
|
||||
// set selectedLinearElement only if thats the only element selected
|
||||
selectedLinearElement:
|
||||
newSelectedElements.length === 1 &&
|
||||
isLinearElement(newSelectedElements[0])
|
||||
? new LinearElementEditor(
|
||||
newSelectedElements[0],
|
||||
this.scene,
|
||||
)
|
||||
: prevState.selectedLinearElement,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// add element to selection while
|
||||
// keeping prev elements selected
|
||||
|
||||
this.setState((_prevState) => ({
|
||||
selectedElementIds: {
|
||||
..._prevState.selectedElementIds,
|
||||
@@ -4706,6 +4935,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
...prevState,
|
||||
selectedElementIds: { [hitElement.id]: true },
|
||||
selectedLinearElement:
|
||||
isLinearElement(hitElement) &&
|
||||
// Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1.
|
||||
// Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized
|
||||
prevState.selectedLinearElement?.elementId !== hitElement.id
|
||||
? new LinearElementEditor(hitElement, this.scene)
|
||||
: prevState.selectedLinearElement,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
),
|
||||
@@ -4714,7 +4950,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (
|
||||
!this.state.editingLinearElement &&
|
||||
!pointerDownState.drag.hasOccurred &&
|
||||
!this.state.isResizing &&
|
||||
((hitElement &&
|
||||
@@ -4727,13 +4962,16 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(!hitElement &&
|
||||
pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
|
||||
) {
|
||||
// Deselect selected elements
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
|
||||
if (this.state.editingLinearElement) {
|
||||
this.setState({ editingLinearElement: null });
|
||||
} else {
|
||||
// Deselect selected elements
|
||||
this.setState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
editingGroupId: null,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5461,6 +5699,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
...this.state,
|
||||
selectedElementIds: { [element.id]: true },
|
||||
selectedLinearElement: isLinearElement(element)
|
||||
? new LinearElementEditor(element, this.scene)
|
||||
: null,
|
||||
},
|
||||
this.scene.getNonDeletedElements(),
|
||||
),
|
||||
@@ -5925,6 +6166,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
if (cb) {
|
||||
cb();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
width,
|
||||
@@ -6001,4 +6249,5 @@ if (
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@@ -4,6 +4,7 @@ import "./Card.scss";
|
||||
|
||||
export const Card: React.FC<{
|
||||
color: keyof OpenColor | "primary";
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, color }) => {
|
||||
return (
|
||||
<div
|
||||
|
@@ -8,6 +8,7 @@ export const CheckboxItem: React.FC<{
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean, event: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, checked, onChange, className }) => {
|
||||
return (
|
||||
<div
|
||||
|
@@ -343,6 +343,8 @@ const ColorInput = React.forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
ColorInput.displayName = "ColorInput";
|
||||
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
|
@@ -85,6 +85,7 @@ export const Dialog = (props: DialogProps) => {
|
||||
<button
|
||||
className="Modal__close"
|
||||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
>
|
||||
{useDevice().isMobile ? back : close}
|
||||
|
99
src/components/Footer.tsx
Normal file
99
src/components/Footer.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import clsx from "clsx";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
UndoRedoActions,
|
||||
ZoomActions,
|
||||
} from "./Actions";
|
||||
import { useDevice } from "./App";
|
||||
import { Island } from "./Island";
|
||||
import { Section } from "./Section";
|
||||
import Stack from "./Stack";
|
||||
|
||||
const Footer = ({
|
||||
appState,
|
||||
actionManager,
|
||||
renderCustomFooter,
|
||||
showExitZenModeBtn,
|
||||
}: {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
showExitZenModeBtn: boolean;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||
>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-left zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{!appState.viewModeEnabled && (
|
||||
<>
|
||||
<UndoRedoActions
|
||||
renderAction={actionManager.renderAction}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showFinalize && (
|
||||
<FinalizeAction
|
||||
renderAction={actionManager.renderAction}
|
||||
className={clsx("zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<ExitZenModeAction
|
||||
executeAction={actionManager.executeAction}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
/>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
@@ -58,6 +58,7 @@ const ExportButton: React.FC<{
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
shade?: number;
|
||||
children?: React.ReactNode;
|
||||
}> = ({ children, title, onClick, color, shade = 6 }) => {
|
||||
return (
|
||||
<button
|
||||
|
@@ -10,7 +10,7 @@ import { calculateScrollCenter, getSelectedElements } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
@@ -39,7 +39,7 @@ import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import Footer from "./Footer";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@@ -71,8 +71,8 @@ const LayerUI = ({
|
||||
appState,
|
||||
files,
|
||||
setAppState,
|
||||
canvas,
|
||||
elements,
|
||||
canvas,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onPenModeToggle,
|
||||
@@ -210,8 +210,8 @@ const LayerUI = ({
|
||||
)}
|
||||
</Stack.Row>
|
||||
<BackgroundPickerAndDarkModeToggle
|
||||
actionManager={actionManager}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
setAppState={setAppState}
|
||||
showThemeBtn={showThemeBtn}
|
||||
/>
|
||||
@@ -244,7 +244,6 @@ const LayerUI = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
@@ -279,7 +278,6 @@ const LayerUI = ({
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
focusContainer={focusContainer}
|
||||
library={library}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
appState={appState}
|
||||
@@ -308,7 +306,7 @@ const LayerUI = ({
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled && (
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
@@ -383,100 +381,7 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderBottomAppMenu = () => {
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
className="layer-ui__wrapper__footer App-menu App-menu_bottom"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-left zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Stack.Col gap={2}>
|
||||
<Section heading="canvasActions">
|
||||
<Island padding={1}>
|
||||
<ZoomActions
|
||||
renderAction={actionManager.renderAction}
|
||||
zoom={appState.zoom}
|
||||
/>
|
||||
</Island>
|
||||
{!appState.viewModeEnabled && (
|
||||
<>
|
||||
<div
|
||||
className={clsx("undo-redo-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("undo", { size: "small" })}
|
||||
{actionManager.renderAction("redo", { size: "small" })}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx("eraser-buttons zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("eraser", { size: "small" })}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.multiElement &&
|
||||
device.isTouchScreen && (
|
||||
<div
|
||||
className={clsx("finalize-button zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-left":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{actionManager.renderAction("finalize", { size: "small" })}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-right zen-mode-transition",
|
||||
{
|
||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
<button
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
onClick={() => actionManager.executeAction(actionToggleZenMode)}
|
||||
>
|
||||
{t("buttons.exitZenMode")}
|
||||
</button>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
const dialogs = (
|
||||
return (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
@@ -504,84 +409,77 @@ const LayerUI = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
|
||||
const renderStats = () => {
|
||||
if (!appState.showStats) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return device.isMobile ? (
|
||||
<>
|
||||
{dialogs}
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
libraryMenu={libraryMenu}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showThemeBtn={showThemeBtn}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderStats={renderStats}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
appState.isLibraryOpen &&
|
||||
appState.isLibraryMenuDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{dialogs}
|
||||
{renderFixedSideContainer()}
|
||||
{renderBottomAppMenu()}
|
||||
{renderStats()}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!device.isMobile && (
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper", {
|
||||
"disable-pointerEvents":
|
||||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
(appState.editingElement &&
|
||||
!isTextElement(appState.editingElement)),
|
||||
})}
|
||||
style={
|
||||
appState.isLibraryOpen &&
|
||||
appState.isLibraryMenuDocked &&
|
||||
device.canDeviceFitSidebar
|
||||
? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
/>
|
||||
{appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({
|
||||
...calculateScrollCenter(elements, appState, canvas),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{appState.isLibraryOpen && (
|
||||
<div className="layer-ui__sidebar">{libraryMenu}</div>
|
||||
)}
|
||||
|
@@ -80,7 +80,6 @@ export const LibraryMenu = ({
|
||||
onInsertLibraryItems,
|
||||
pendingElements,
|
||||
onAddToLibrary,
|
||||
theme,
|
||||
setAppState,
|
||||
files,
|
||||
libraryReturnUrl,
|
||||
@@ -93,7 +92,6 @@ export const LibraryMenu = ({
|
||||
onClose: () => void;
|
||||
onInsertLibraryItems: (libraryItems: LibraryItems) => void;
|
||||
onAddToLibrary: () => void;
|
||||
theme: AppState["theme"];
|
||||
files: BinaryFiles;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
@@ -105,7 +103,6 @@ export const LibraryMenu = ({
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const device = useDevice();
|
||||
|
||||
useOnClickOutside(
|
||||
ref,
|
||||
useCallback(
|
||||
@@ -224,7 +221,7 @@ export const LibraryMenu = ({
|
||||
}, [setPublishLibSuccess, publishLibSuccess]);
|
||||
|
||||
const onPublishLibSuccess = useCallback(
|
||||
(data, libraryItems: LibraryItems) => {
|
||||
(data: { url: string; authorName: string }, libraryItems: LibraryItems) => {
|
||||
setShowPublishLibraryDialog(false);
|
||||
setPublishLibSuccess({ url: data.url, authorName: data.authorName });
|
||||
const nextLibItems = libraryItems.slice();
|
||||
@@ -290,7 +287,7 @@ export const LibraryMenu = ({
|
||||
appState={appState}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
library={library}
|
||||
theme={theme}
|
||||
theme={appState.theme}
|
||||
files={files}
|
||||
id={id}
|
||||
selectedItems={selectedItems}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState } from "../types";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@@ -18,6 +18,8 @@ import { UserList } from "./UserList";
|
||||
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@@ -42,7 +44,7 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderStats: () => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
@@ -62,13 +64,13 @@ export const MobileMenu = ({
|
||||
showThemeBtn,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderStats,
|
||||
renderCustomStats,
|
||||
}: MobileMenuProps) => {
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1} className="App-toolbar-container">
|
||||
<Island padding={1} className="App-toolbar">
|
||||
@@ -184,7 +186,17 @@ export const MobileMenu = ({
|
||||
return (
|
||||
<>
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{renderStats()}
|
||||
{!appState.openMenu && appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
@@ -221,7 +233,6 @@ export const MobileMenu = ({
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
activeTool={appState.activeTool.type}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
|
@@ -8,7 +8,7 @@ import { useExcalidrawContainer, useDevice } from "./App";
|
||||
import { AppState } from "../types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
export const Modal = (props: {
|
||||
export const Modal: React.FC<{
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
@@ -16,7 +16,7 @@ export const Modal = (props: {
|
||||
labelledBy: string;
|
||||
theme?: AppState["theme"];
|
||||
closeOnClickOutside?: boolean;
|
||||
}) => {
|
||||
}> = (props) => {
|
||||
const { theme = THEME.LIGHT, closeOnClickOutside = true } = props;
|
||||
const modalRoot = useBodyRoot(theme);
|
||||
|
||||
|
@@ -46,7 +46,7 @@ const ChartPreviewBtn = (props: {
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
@@ -55,7 +55,7 @@ const ChartPreviewBtn = (props: {
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.removeChild(svg);
|
||||
previewNode.replaceChildren();
|
||||
};
|
||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||
|
||||
|
@@ -69,7 +69,6 @@ export const Popover = ({
|
||||
if (fitInViewport && popoverRef.current) {
|
||||
const element = popoverRef.current;
|
||||
const { x, y, width, height } = element.getBoundingClientRect();
|
||||
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
|
||||
|
||||
//Position correctly when clicked on rightmost part or the bottom part of viewport
|
||||
if (x + width - offsetLeft > viewportWidth) {
|
||||
|
@@ -2,12 +2,11 @@ import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
export const Section: React.FC<{
|
||||
heading: string;
|
||||
children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
|
||||
}
|
||||
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
|
||||
className?: string;
|
||||
}> = ({ heading, children, ...props }) => {
|
||||
const { id } = useExcalidrawContainer();
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${id}-${heading}-title`}>
|
||||
|
@@ -2,7 +2,6 @@ import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import { getTargetElements } from "../scene";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import { close } from "./icons";
|
||||
@@ -16,13 +15,10 @@ export const Stats = (props: {
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
if (device.isMobile && props.appState.openMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
|
@@ -187,3 +187,5 @@ ToolButton.defaultProps = {
|
||||
className: "",
|
||||
size: "medium",
|
||||
};
|
||||
|
||||
ToolButton.displayName = "ToolButton";
|
||||
|
@@ -67,13 +67,14 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends ExcalidrawElement,
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: Required<T> & {
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
},
|
||||
K extends Pick<T, keyof Omit<Required<T>, keyof ExcalidrawElement>>,
|
||||
>(
|
||||
element: T,
|
||||
extra: Pick<
|
||||
T,
|
||||
// This extra Pick<T, keyof K> ensure no excess properties are passed.
|
||||
@@ -115,6 +116,10 @@ const restoreElementWithProperties = <
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
if ("customData" in element) {
|
||||
base.customData = element.customData;
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...getNormalizedDimensions(base),
|
||||
|
@@ -32,6 +32,7 @@ import { getElementAbsoluteCoords } from "./";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
@@ -48,15 +49,15 @@ let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
appState,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
appState: AppState;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
|
||||
const linkVal = element.link || "";
|
||||
|
||||
const [inputVal, setInputVal] = useState(linkVal);
|
||||
|
@@ -18,6 +18,7 @@ import { rescalePoints } from "../points";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
@@ -68,11 +69,102 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
return shape.sets[0].ops;
|
||||
};
|
||||
|
||||
// reference: https://eliot-jones.com/2019/12/cubic-bezier-curve-bounding-boxes
|
||||
const getBezierValueForT = (
|
||||
t: number,
|
||||
p0: number,
|
||||
p1: number,
|
||||
p2: number,
|
||||
p3: number,
|
||||
) => {
|
||||
const oneMinusT = 1 - t;
|
||||
return (
|
||||
Math.pow(oneMinusT, 3) * p0 +
|
||||
3 * Math.pow(oneMinusT, 2) * t * p1 +
|
||||
3 * oneMinusT * Math.pow(t, 2) * p2 +
|
||||
Math.pow(t, 3) * p3
|
||||
);
|
||||
};
|
||||
|
||||
const solveQuadratic = (
|
||||
p0: number,
|
||||
p1: number,
|
||||
p2: number,
|
||||
p3: number,
|
||||
): MaybeQuadraticSolution => {
|
||||
const i = p1 - p0;
|
||||
const j = p2 - p1;
|
||||
const k = p3 - p2;
|
||||
|
||||
const a = 3 * i - 6 * j + 3 * k;
|
||||
const b = 6 * j - 6 * i;
|
||||
const c = 3 * i;
|
||||
|
||||
const sqrtPart = b * b - 4 * a * c;
|
||||
const hasSolution = sqrtPart >= 0;
|
||||
|
||||
if (!hasSolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let s1 = null;
|
||||
let s2 = null;
|
||||
|
||||
let t1 = Infinity;
|
||||
let t2 = Infinity;
|
||||
|
||||
if (a === 0) {
|
||||
t1 = t2 = -c / b;
|
||||
} else {
|
||||
t1 = (-b + Math.sqrt(sqrtPart)) / (2 * a);
|
||||
t2 = (-b - Math.sqrt(sqrtPart)) / (2 * a);
|
||||
}
|
||||
|
||||
if (t1 >= 0 && t1 <= 1) {
|
||||
s1 = getBezierValueForT(t1, p0, p1, p2, p3);
|
||||
}
|
||||
|
||||
if (t2 >= 0 && t2 <= 1) {
|
||||
s2 = getBezierValueForT(t2, p0, p1, p2, p3);
|
||||
}
|
||||
|
||||
return [s1, s2];
|
||||
};
|
||||
|
||||
const getCubicBezierCurveBound = (
|
||||
p0: Point,
|
||||
p1: Point,
|
||||
p2: Point,
|
||||
p3: Point,
|
||||
): Bounds => {
|
||||
const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
|
||||
const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
|
||||
|
||||
let minX = Math.min(p0[0], p3[0]);
|
||||
let maxX = Math.max(p0[0], p3[0]);
|
||||
|
||||
if (solX) {
|
||||
const xs = solX.filter((x) => x !== null) as number[];
|
||||
minX = Math.min(minX, ...xs);
|
||||
maxX = Math.max(maxX, ...xs);
|
||||
}
|
||||
|
||||
let minY = Math.min(p0[1], p3[1]);
|
||||
let maxY = Math.max(p0[1], p3[1]);
|
||||
if (solY) {
|
||||
const ys = solY.filter((y) => y !== null) as number[];
|
||||
minY = Math.min(minY, ...ys);
|
||||
maxY = Math.max(maxY, ...ys);
|
||||
}
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
transformXY?: (x: number, y: number) => [number, number],
|
||||
): [number, number, number, number] => {
|
||||
let currentP: Point = [0, 0];
|
||||
|
||||
const { minX, minY, maxX, maxY } = ops.reduce(
|
||||
(limits, { op, data }) => {
|
||||
// There are only four operation types:
|
||||
@@ -83,38 +175,29 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
// move operation does not draw anything; so, it always
|
||||
// returns false
|
||||
} else if (op === "bcurveTo") {
|
||||
// create points from bezier curve
|
||||
// bezier curve stores data as a flattened array of three positions
|
||||
// [x1, y1, x2, y2, x3, y3]
|
||||
const p1 = [data[0], data[1]] as Point;
|
||||
const p2 = [data[2], data[3]] as Point;
|
||||
const p3 = [data[4], data[5]] as Point;
|
||||
const _p1 = [data[0], data[1]] as Point;
|
||||
const _p2 = [data[2], data[3]] as Point;
|
||||
const _p3 = [data[4], data[5]] as Point;
|
||||
|
||||
const p0 = currentP;
|
||||
currentP = p3;
|
||||
const p1 = transformXY ? transformXY(..._p1) : _p1;
|
||||
const p2 = transformXY ? transformXY(..._p2) : _p2;
|
||||
const p3 = transformXY ? transformXY(..._p3) : _p3;
|
||||
|
||||
const equation = (t: number, idx: number) =>
|
||||
Math.pow(1 - t, 3) * p3[idx] +
|
||||
3 * t * Math.pow(1 - t, 2) * p2[idx] +
|
||||
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
|
||||
p0[idx] * Math.pow(t, 3);
|
||||
const p0 = transformXY ? transformXY(...currentP) : currentP;
|
||||
currentP = _p3;
|
||||
|
||||
let t = 0;
|
||||
while (t <= 1.0) {
|
||||
let x = equation(t, 0);
|
||||
let y = equation(t, 1);
|
||||
if (transformXY) {
|
||||
[x, y] = transformXY(x, y);
|
||||
}
|
||||
const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
|
||||
p0,
|
||||
p1,
|
||||
p2,
|
||||
p3,
|
||||
);
|
||||
|
||||
limits.minY = Math.min(limits.minY, y);
|
||||
limits.minX = Math.min(limits.minX, x);
|
||||
limits.minX = Math.min(limits.minX, minX);
|
||||
limits.minY = Math.min(limits.minY, minY);
|
||||
|
||||
limits.maxX = Math.max(limits.maxX, x);
|
||||
limits.maxY = Math.max(limits.maxY, y);
|
||||
|
||||
t += 0.1;
|
||||
}
|
||||
limits.maxX = Math.max(limits.maxX, maxX);
|
||||
limits.maxY = Math.max(limits.maxY, maxY);
|
||||
} else if (op === "lineTo") {
|
||||
// TODO: Implement this
|
||||
} else if (op === "qcurveTo") {
|
||||
@@ -124,7 +207,6 @@ const getMinMaxXYFromCurvePathOps = (
|
||||
},
|
||||
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||
);
|
||||
|
||||
return [minX, minY, maxX, maxY];
|
||||
};
|
||||
|
||||
@@ -420,6 +502,7 @@ export const getResizedElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
normalizePoints: boolean,
|
||||
): [number, number, number, number] => {
|
||||
if (!(isLinearElement(element) || isFreeDrawElement(element))) {
|
||||
return [
|
||||
@@ -433,7 +516,8 @@ export const getResizedElementAbsoluteCoords = (
|
||||
const points = rescalePoints(
|
||||
0,
|
||||
nextWidth,
|
||||
rescalePoints(1, nextHeight, element.points),
|
||||
rescalePoints(1, nextHeight, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
);
|
||||
|
||||
let bounds: [number, number, number, number];
|
||||
|
@@ -35,6 +35,7 @@ import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
@@ -64,7 +65,10 @@ export const hitTest = (
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
const point: Point = [x, y];
|
||||
|
||||
if (isElementSelected(appState, element)) {
|
||||
if (
|
||||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
}
|
||||
|
||||
|
@@ -105,15 +105,26 @@ export const dragNewElement = (
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
) => {
|
||||
if (shouldMaintainAspectRatio) {
|
||||
if (shouldMaintainAspectRatio && draggingElement.type !== "selection") {
|
||||
if (widthAspectRatio) {
|
||||
height = width / widthAspectRatio;
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
// Depending on where the cursor is at (x, y) relative to where the starting point is
|
||||
// (originX, originY), we use ONLY width or height to control size increase.
|
||||
// This allows the cursor to always "stick" to one of the sides of the bounding box.
|
||||
if (Math.abs(y - originY) > Math.abs(x - originX)) {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
height,
|
||||
x < originX ? -width : width,
|
||||
));
|
||||
} else {
|
||||
({ width, height } = getPerfectElementSize(
|
||||
elementType,
|
||||
width,
|
||||
y < originY ? -height : height,
|
||||
));
|
||||
}
|
||||
|
||||
if (height < 0) {
|
||||
height = -height;
|
||||
|
@@ -53,6 +53,7 @@ export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
getPerfectElementSize,
|
||||
getLockedLinearCursorAlignSize,
|
||||
isInvisiblySmallElement,
|
||||
resizePerfectLineForNWHandler,
|
||||
getNormalizedDimensions,
|
||||
|
@@ -5,8 +5,15 @@ import {
|
||||
PointBinding,
|
||||
ExcalidrawBindableElement,
|
||||
} from "./types";
|
||||
import { distance2d, rotate, isPathALoop, getGridPoint } from "../math";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import {
|
||||
distance2d,
|
||||
rotate,
|
||||
isPathALoop,
|
||||
getGridPoint,
|
||||
rotatePoint,
|
||||
centerPoint,
|
||||
} from "../math";
|
||||
import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
|
||||
import { getElementPointsCoords } from "./bounds";
|
||||
import { Point, AppState } from "../types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
@@ -20,26 +27,32 @@ import {
|
||||
} from "./binding";
|
||||
import { tupleToCoors } from "../utils";
|
||||
import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
|
||||
export class LinearElementEditor {
|
||||
public elementId: ExcalidrawElement["id"] & {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
};
|
||||
/** indices */
|
||||
public selectedPointsIndices: readonly number[] | null;
|
||||
public readonly selectedPointsIndices: readonly number[] | null;
|
||||
|
||||
public pointerDownState: Readonly<{
|
||||
public readonly pointerDownState: Readonly<{
|
||||
prevSelectedPointsIndices: readonly number[] | null;
|
||||
/** index */
|
||||
lastClickedPoint: number;
|
||||
}>;
|
||||
|
||||
/** whether you're dragging a point */
|
||||
public isDragging: boolean;
|
||||
public lastUncommittedPoint: Point | null;
|
||||
public pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public startBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly isDragging: boolean;
|
||||
public readonly lastUncommittedPoint: Point | null;
|
||||
public readonly pointerOffset: Readonly<{ x: number; y: number }>;
|
||||
public readonly startBindingElement:
|
||||
| ExcalidrawBindableElement
|
||||
| null
|
||||
| "keep";
|
||||
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly midPointHovered: boolean;
|
||||
|
||||
constructor(element: NonDeleted<ExcalidrawLinearElement>, scene: Scene) {
|
||||
this.elementId = element.id as string & {
|
||||
@@ -58,13 +71,15 @@ export class LinearElementEditor {
|
||||
prevSelectedPointsIndices: null,
|
||||
lastClickedPoint: -1,
|
||||
};
|
||||
this.hoverPointIndex = -1;
|
||||
this.midPointHovered = false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// static methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static POINT_HANDLE_SIZE = 20;
|
||||
static POINT_HANDLE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* @param id the `elementId` from the instance of this class (so that we can
|
||||
@@ -132,22 +147,20 @@ export class LinearElementEditor {
|
||||
|
||||
/** @returns whether point was dragged */
|
||||
static handlePointDragging(
|
||||
event: PointerEvent,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
maybeSuggestBinding: (
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
pointSceneCoords: { x: number; y: number }[],
|
||||
) => void,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): boolean {
|
||||
if (!appState.editingLinearElement) {
|
||||
if (!linearElementEditor) {
|
||||
return false;
|
||||
}
|
||||
const { editingLinearElement } = appState;
|
||||
const { selectedPointsIndices, elementId, isDragging } =
|
||||
editingLinearElement;
|
||||
|
||||
const { selectedPointsIndices, elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
@@ -155,54 +168,71 @@ export class LinearElementEditor {
|
||||
|
||||
// point that's being dragged (out of all selected points)
|
||||
const draggingPoint = element.points[
|
||||
editingLinearElement.pointerDownState.lastClickedPoint
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
] as [number, number] | undefined;
|
||||
|
||||
if (selectedPointsIndices && draggingPoint) {
|
||||
if (isDragging === false) {
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...editingLinearElement,
|
||||
isDragging: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
shouldRotateWithDiscreteAngle(event) &&
|
||||
selectedPointsIndices.length === 1 &&
|
||||
element.points.length > 1
|
||||
) {
|
||||
const selectedIndex = selectedPointsIndices[0];
|
||||
const referencePoint =
|
||||
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
);
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
referencePoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
appState.gridSize,
|
||||
);
|
||||
|
||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition =
|
||||
pointIndex ===
|
||||
editingLinearElement.pointerDownState.lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
)
|
||||
: ([
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
] as const);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
LinearElementEditor.movePoints(element, [
|
||||
{
|
||||
index: selectedIndex,
|
||||
point: [width + referencePoint[0], height + referencePoint[1]],
|
||||
isDragging:
|
||||
selectedIndex ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const newDraggingPointPosition = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
);
|
||||
|
||||
const deltaX = newDraggingPointPosition[0] - draggingPoint[0];
|
||||
const deltaY = newDraggingPointPosition[1] - draggingPoint[1];
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
selectedPointsIndices.map((pointIndex) => {
|
||||
const newPointPosition =
|
||||
pointIndex ===
|
||||
editingLinearElement.pointerDownState.lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
);
|
||||
linearElementEditor.pointerDownState.lastClickedPoint
|
||||
? LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
appState.gridSize,
|
||||
)
|
||||
: ([
|
||||
element.points[pointIndex][0] + deltaX,
|
||||
element.points[pointIndex][1] + deltaY,
|
||||
] as const);
|
||||
return {
|
||||
index: pointIndex,
|
||||
point: newPointPosition,
|
||||
isDragging:
|
||||
pointIndex ===
|
||||
linearElementEditor.pointerDownState.lastClickedPoint,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
if (isBindingElement(element, false)) {
|
||||
@@ -256,10 +286,12 @@ export class LinearElementEditor {
|
||||
return editingLinearElement;
|
||||
}
|
||||
|
||||
const bindings: Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
const bindings: Mutable<
|
||||
Partial<
|
||||
Pick<
|
||||
InstanceType<typeof LinearElementEditor>,
|
||||
"startBindingElement" | "endBindingElement"
|
||||
>
|
||||
>
|
||||
> = {};
|
||||
|
||||
@@ -327,34 +359,126 @@ export class LinearElementEditor {
|
||||
};
|
||||
}
|
||||
|
||||
static isHittingMidPoint = (
|
||||
linearElementEditor: LinearElementEditor,
|
||||
scenePointer: { x: number; y: number },
|
||||
appState: AppState,
|
||||
) => {
|
||||
const { elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor(
|
||||
element,
|
||||
appState.zoom,
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
if (clickedPointIndex >= 0) {
|
||||
return false;
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
if (points.length >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
||||
if (midPoint) {
|
||||
const threshold =
|
||||
LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value;
|
||||
const distance = distance2d(
|
||||
midPoint[0],
|
||||
midPoint[1],
|
||||
scenePointer.x,
|
||||
scenePointer.y,
|
||||
);
|
||||
return distance <= threshold;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
static getMidPoint(linearElementEditor: LinearElementEditor) {
|
||||
const { elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
|
||||
return centerPoint(points[0], points.at(-1)!);
|
||||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
appState: AppState,
|
||||
setState: React.Component<any, AppState>["setState"],
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
linearElementEditor: LinearElementEditor,
|
||||
): {
|
||||
didAddPoint: boolean;
|
||||
hitElement: NonDeleted<ExcalidrawElement> | null;
|
||||
linearElementEditor: LinearElementEditor | null;
|
||||
isMidPoint: boolean;
|
||||
} {
|
||||
const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
|
||||
didAddPoint: false,
|
||||
hitElement: null,
|
||||
linearElementEditor: null,
|
||||
isMidPoint: false,
|
||||
};
|
||||
|
||||
if (!appState.editingLinearElement) {
|
||||
if (!linearElementEditor) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
const { elementId } = appState.editingLinearElement;
|
||||
const { elementId } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
|
||||
if (!element) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (event.altKey) {
|
||||
if (appState.editingLinearElement.lastUncommittedPoint == null) {
|
||||
const hittingMidPoint = LinearElementEditor.isHittingMidPoint(
|
||||
linearElementEditor,
|
||||
scenePointer,
|
||||
appState,
|
||||
);
|
||||
if (
|
||||
LinearElementEditor.isHittingMidPoint(
|
||||
linearElementEditor,
|
||||
scenePointer,
|
||||
appState,
|
||||
)
|
||||
) {
|
||||
const midPoint = LinearElementEditor.getMidPoint(linearElementEditor);
|
||||
if (midPoint) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
element.points[0],
|
||||
LinearElementEditor.createPointAt(
|
||||
element,
|
||||
midPoint[0],
|
||||
midPoint[1],
|
||||
appState.gridSize,
|
||||
),
|
||||
...element.points.slice(1),
|
||||
],
|
||||
});
|
||||
}
|
||||
ret.didAddPoint = true;
|
||||
ret.isMidPoint = true;
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: element.points[1],
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
lastUncommittedPoint: null,
|
||||
};
|
||||
}
|
||||
if (event.altKey && appState.editingLinearElement) {
|
||||
if (linearElementEditor.lastUncommittedPoint == null) {
|
||||
mutateElement(element, {
|
||||
points: [
|
||||
...element.points,
|
||||
@@ -366,24 +490,23 @@ export class LinearElementEditor {
|
||||
),
|
||||
],
|
||||
});
|
||||
ret.didAddPoint = true;
|
||||
}
|
||||
history.resumeRecording();
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
appState.editingLinearElement.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
Scene.getScene(element)!,
|
||||
),
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: -1,
|
||||
},
|
||||
});
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
Scene.getScene(element)!,
|
||||
),
|
||||
};
|
||||
|
||||
ret.didAddPoint = true;
|
||||
return ret;
|
||||
}
|
||||
@@ -397,7 +520,7 @@ export class LinearElementEditor {
|
||||
|
||||
// if we clicked on a point, set the element as hitElement otherwise
|
||||
// it would get deselected if the point is outside the hitbox area
|
||||
if (clickedPointIndex > -1) {
|
||||
if (clickedPointIndex >= 0 || hittingMidPoint) {
|
||||
ret.hitElement = element;
|
||||
} else {
|
||||
// You might be wandering why we are storing the binding elements on
|
||||
@@ -405,8 +528,7 @@ export class LinearElementEditor {
|
||||
// from the end points of the `linearElement` - this is to allow disabling
|
||||
// binding (which needs to happen at the point the user finishes moving
|
||||
// the point).
|
||||
const { startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||
if (isBindingEnabled(appState) && isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
@@ -432,33 +554,28 @@ export class LinearElementEditor {
|
||||
const nextSelectedPointsIndices =
|
||||
clickedPointIndex > -1 || event.shiftKey
|
||||
? event.shiftKey ||
|
||||
appState.editingLinearElement.selectedPointsIndices?.includes(
|
||||
clickedPointIndex,
|
||||
)
|
||||
linearElementEditor.selectedPointsIndices?.includes(clickedPointIndex)
|
||||
? normalizeSelectedPoints([
|
||||
...(appState.editingLinearElement.selectedPointsIndices || []),
|
||||
...(linearElementEditor.selectedPointsIndices || []),
|
||||
clickedPointIndex,
|
||||
])
|
||||
: [clickedPointIndex]
|
||||
: null;
|
||||
|
||||
setState({
|
||||
editingLinearElement: {
|
||||
...appState.editingLinearElement,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices:
|
||||
appState.editingLinearElement.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
},
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
ret.linearElementEditor = {
|
||||
...linearElementEditor,
|
||||
pointerDownState: {
|
||||
prevSelectedPointsIndices: linearElementEditor.selectedPointsIndices,
|
||||
lastClickedPoint: clickedPointIndex,
|
||||
},
|
||||
});
|
||||
selectedPointsIndices: nextSelectedPointsIndices,
|
||||
pointerOffset: targetPoint
|
||||
? {
|
||||
x: scenePointer.x - targetPoint[0],
|
||||
y: scenePointer.y - targetPoint[1],
|
||||
}
|
||||
: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -466,13 +583,13 @@ export class LinearElementEditor {
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
scenePointerX: number,
|
||||
scenePointerY: number,
|
||||
editingLinearElement: LinearElementEditor,
|
||||
linearElementEditor: LinearElementEditor,
|
||||
gridSize: number | null,
|
||||
): LinearElementEditor {
|
||||
const { elementId, lastUncommittedPoint } = editingLinearElement;
|
||||
const { elementId, lastUncommittedPoint } = linearElementEditor;
|
||||
const element = LinearElementEditor.getElement(elementId);
|
||||
if (!element) {
|
||||
return editingLinearElement;
|
||||
return linearElementEditor;
|
||||
}
|
||||
|
||||
const { points } = element;
|
||||
@@ -482,15 +599,33 @@ export class LinearElementEditor {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, [points.length - 1]);
|
||||
}
|
||||
return { ...editingLinearElement, lastUncommittedPoint: null };
|
||||
return { ...linearElementEditor, lastUncommittedPoint: null };
|
||||
}
|
||||
|
||||
const newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - editingLinearElement.pointerOffset.x,
|
||||
scenePointerY - editingLinearElement.pointerOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
let newPoint: Point;
|
||||
|
||||
if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
|
||||
const lastCommittedPoint = points[points.length - 2];
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
lastCommittedPoint,
|
||||
[scenePointerX, scenePointerY],
|
||||
gridSize,
|
||||
);
|
||||
|
||||
newPoint = [
|
||||
width + lastCommittedPoint[0],
|
||||
height + lastCommittedPoint[1],
|
||||
];
|
||||
} else {
|
||||
newPoint = LinearElementEditor.createPointAt(
|
||||
element,
|
||||
scenePointerX - linearElementEditor.pointerOffset.x,
|
||||
scenePointerY - linearElementEditor.pointerOffset.y,
|
||||
gridSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.movePoints(element, [
|
||||
@@ -504,7 +639,7 @@ export class LinearElementEditor {
|
||||
}
|
||||
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...linearElementEditor,
|
||||
lastUncommittedPoint: element.points[element.points.length - 1],
|
||||
};
|
||||
}
|
||||
@@ -526,14 +661,14 @@ export class LinearElementEditor {
|
||||
/** scene coords */
|
||||
static getPointsGlobalCoordinates(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
) {
|
||||
): Point[] {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
return element.points.map((point) => {
|
||||
let { x, y } = element;
|
||||
[x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return [x, y];
|
||||
return [x, y] as const;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -551,7 +686,9 @@ export class LinearElementEditor {
|
||||
|
||||
const point = element.points[index];
|
||||
const { x, y } = element;
|
||||
return rotate(x + point[0], y + point[1], cx, cy, element.angle);
|
||||
return point
|
||||
? rotate(x + point[0], y + point[1], cx, cy, element.angle)
|
||||
: rotate(x, y, cx, cy, element.angle);
|
||||
}
|
||||
|
||||
static pointFromAbsoluteCoords(
|
||||
@@ -577,7 +714,8 @@ export class LinearElementEditor {
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const pointHandles = this.getPointsGlobalCoordinates(element);
|
||||
const pointHandles =
|
||||
LinearElementEditor.getPointsGlobalCoordinates(element);
|
||||
let idx = pointHandles.length;
|
||||
// loop from right to left because points on the right are rendered over
|
||||
// points on the left, thus should take precedence when clicking, if they
|
||||
@@ -587,7 +725,7 @@ export class LinearElementEditor {
|
||||
if (
|
||||
distance2d(x, y, point[0], point[1]) * zoom.value <
|
||||
// +1px to account for outline stroke
|
||||
this.POINT_HANDLE_SIZE / 2 + 1
|
||||
LinearElementEditor.POINT_HANDLE_SIZE + 1
|
||||
) {
|
||||
return idx;
|
||||
}
|
||||
@@ -775,9 +913,9 @@ export class LinearElementEditor {
|
||||
|
||||
if (selectedOriginPoint) {
|
||||
offsetX =
|
||||
selectedOriginPoint.point[0] - points[selectedOriginPoint.index][0];
|
||||
selectedOriginPoint.point[0] + points[selectedOriginPoint.index][0];
|
||||
offsetY =
|
||||
selectedOriginPoint.point[1] - points[selectedOriginPoint.index][1];
|
||||
selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
|
||||
}
|
||||
|
||||
const nextPoints = points.map((point, idx) => {
|
||||
@@ -840,6 +978,33 @@ export class LinearElementEditor {
|
||||
y: element.y + rotated[1],
|
||||
});
|
||||
}
|
||||
|
||||
private static _getShiftLockedDelta(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
referencePoint: Point,
|
||||
scenePointer: Point,
|
||||
gridSize: number | null,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
referencePoint,
|
||||
);
|
||||
|
||||
const [gridX, gridY] = getGridPoint(
|
||||
scenePointer[0],
|
||||
scenePointer[1],
|
||||
gridSize,
|
||||
);
|
||||
|
||||
const { width, height } = getLockedLinearCursorAlignSize(
|
||||
referencePointCoords[0],
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
);
|
||||
|
||||
return rotatePoint([width, height], [0, 0], -element.angle);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSelectedPoints = (
|
||||
|
@@ -198,6 +198,7 @@ const getAdjustedDimensions = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
|
@@ -18,6 +18,7 @@ import {
|
||||
getElementAbsoluteCoords,
|
||||
getCommonBounds,
|
||||
getResizedElementAbsoluteCoords,
|
||||
getCommonBoundingBox,
|
||||
} from "./bounds";
|
||||
import {
|
||||
isFreeDrawElement,
|
||||
@@ -137,8 +138,10 @@ export const transformElements = (
|
||||
transformHandleType === "se"
|
||||
) {
|
||||
resizeMultipleElements(
|
||||
pointerDownState,
|
||||
selectedElements,
|
||||
transformHandleType,
|
||||
shouldResizeFromCenter,
|
||||
pointerX,
|
||||
pointerY,
|
||||
);
|
||||
@@ -261,13 +264,15 @@ const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
normalizePoints: boolean,
|
||||
) =>
|
||||
isLinearElement(element) || isFreeDrawElement(element)
|
||||
? {
|
||||
points: rescalePoints(
|
||||
0,
|
||||
width,
|
||||
rescalePoints(1, height, element.points),
|
||||
rescalePoints(1, height, element.points, normalizePoints),
|
||||
normalizePoints,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
@@ -371,6 +376,7 @@ const resizeSingleTextElement = (
|
||||
element,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
);
|
||||
const deltaX1 = (x1 - nextX1) / 2;
|
||||
const deltaY1 = (y1 - nextY1) / 2;
|
||||
@@ -412,6 +418,7 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
@@ -429,6 +436,7 @@ export const resizeSingleElement = (
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
true,
|
||||
);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
@@ -522,6 +530,7 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
@@ -592,6 +601,7 @@ export const resizeSingleElement = (
|
||||
stateAtResizeStart,
|
||||
eleNewWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
// For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
|
||||
// So we need to readjust (x,y) to be where the first point should be
|
||||
@@ -637,146 +647,147 @@ export const resizeSingleElement = (
|
||||
};
|
||||
|
||||
const resizeMultipleElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
pointerDownState: PointerDownState,
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[],
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
let scale: number;
|
||||
let getNextXY: (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
origCoords: readonly [number, number, number, number],
|
||||
finalCoords: readonly [number, number, number, number],
|
||||
) => { x: number; y: number };
|
||||
switch (transformHandleType) {
|
||||
case "se":
|
||||
scale = Math.max(
|
||||
(pointerX - x1) / (x2 - x1),
|
||||
(pointerY - y1) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => {
|
||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "nw":
|
||||
scale = Math.max(
|
||||
(x2 - pointerX) / (x2 - x1),
|
||||
(y2 - pointerY) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => {
|
||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "ne":
|
||||
scale = Math.max(
|
||||
(pointerX - x1) / (x2 - x1),
|
||||
(y2 - pointerY) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => {
|
||||
const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1;
|
||||
const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
case "sw":
|
||||
scale = Math.max(
|
||||
(x2 - pointerX) / (x2 - x1),
|
||||
(pointerY - y1) / (y2 - y1),
|
||||
);
|
||||
getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => {
|
||||
const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2;
|
||||
const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1;
|
||||
return { x, y };
|
||||
};
|
||||
break;
|
||||
// map selected elements to the original elements. While it never should
|
||||
// happen that pointerDownState.originalElements won't contain the selected
|
||||
// elements during resize, this coupling isn't guaranteed, so to ensure
|
||||
// type safety we need to transform only those elements we filter.
|
||||
const targetElements = selectedElements.reduce(
|
||||
(
|
||||
acc: {
|
||||
/** element at resize start */
|
||||
orig: NonDeletedExcalidrawElement;
|
||||
/** latest element */
|
||||
latest: NonDeletedExcalidrawElement;
|
||||
}[],
|
||||
element,
|
||||
) => {
|
||||
const origElement = pointerDownState.originalElements.get(element.id);
|
||||
if (origElement) {
|
||||
acc.push({ orig: origElement, latest: element });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { minX, minY, maxX, maxY, midX, midY } = getCommonBoundingBox(
|
||||
targetElements.map(({ orig }) => orig),
|
||||
);
|
||||
const direction = transformHandleType;
|
||||
|
||||
const mapDirectionsToAnchors: Record<typeof direction, Point> = {
|
||||
ne: [minX, maxY],
|
||||
se: [minX, minY],
|
||||
sw: [maxX, minY],
|
||||
nw: [maxX, maxY],
|
||||
};
|
||||
|
||||
// anchor point must be on the opposite side of the dragged selection handle
|
||||
// or be the center of the selection if alt is pressed
|
||||
const [anchorX, anchorY]: Point = shouldResizeFromCenter
|
||||
? [midX, midY]
|
||||
: mapDirectionsToAnchors[direction];
|
||||
|
||||
const mapDirectionsToPointerSides: Record<
|
||||
typeof direction,
|
||||
[x: boolean, y: boolean]
|
||||
> = {
|
||||
ne: [pointerX >= anchorX, pointerY <= anchorY],
|
||||
se: [pointerX >= anchorX, pointerY >= anchorY],
|
||||
sw: [pointerX <= anchorX, pointerY >= anchorY],
|
||||
nw: [pointerX <= anchorX, pointerY <= anchorY],
|
||||
};
|
||||
|
||||
// pointer side relative to anchor
|
||||
const [pointerSideX, pointerSideY] = mapDirectionsToPointerSides[
|
||||
direction
|
||||
].map((condition) => (condition ? 1 : -1));
|
||||
|
||||
// stop resizing if a pointer is on the other side of selection
|
||||
if (pointerSideX < 0 && pointerSideY < 0) {
|
||||
return;
|
||||
}
|
||||
if (scale > 0) {
|
||||
const updates = elements.reduce(
|
||||
(prev, element) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
|
||||
const scale =
|
||||
Math.max(
|
||||
(pointerSideX * Math.abs(pointerX - anchorX)) / (maxX - minX),
|
||||
(pointerSideY * Math.abs(pointerY - anchorY)) / (maxY - minY),
|
||||
) * (shouldResizeFromCenter ? 2 : 1);
|
||||
|
||||
if (scale === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElements.forEach((element) => {
|
||||
const width = element.orig.width * scale;
|
||||
const height = element.orig.height * scale;
|
||||
const x = anchorX + (element.orig.x - anchorX) * scale;
|
||||
const y = anchorY + (element.orig.y - anchorY) * scale;
|
||||
|
||||
// readjust points for linear & free draw elements
|
||||
const rescaledPoints = rescalePointsInElement(
|
||||
element.orig,
|
||||
width,
|
||||
height,
|
||||
false,
|
||||
);
|
||||
|
||||
const update: {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
points?: Point[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
} = {
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
...rescaledPoints,
|
||||
};
|
||||
|
||||
let boundTextUpdates: { fontSize: number; baseline: number } | null = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(element.latest);
|
||||
|
||||
if (boundTextElement || isTextElement(element.orig)) {
|
||||
const optionalPadding = boundTextElement ? BOUND_TEXT_PADDING * 2 : 0;
|
||||
const textMeasurements = measureFontSizeFromWH(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
width - optionalPadding,
|
||||
height - optionalPadding,
|
||||
);
|
||||
if (textMeasurements) {
|
||||
if (isTextElement(element.orig)) {
|
||||
update.fontSize = textMeasurements.size;
|
||||
update.baseline = textMeasurements.baseline;
|
||||
}
|
||||
const width = element.width * scale;
|
||||
const height = element.height * scale;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
let font: { fontSize?: number; baseline?: number } = {};
|
||||
|
||||
if (boundTextElement) {
|
||||
const nextFont = measureFontSizeFromWH(
|
||||
boundTextElement,
|
||||
width - BOUND_TEXT_PADDING * 2,
|
||||
height - BOUND_TEXT_PADDING * 2,
|
||||
);
|
||||
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
}
|
||||
font = {
|
||||
fontSize: nextFont.size,
|
||||
baseline: nextFont.baseline,
|
||||
boundTextUpdates = {
|
||||
fontSize: textMeasurements.size,
|
||||
baseline: textMeasurements.baseline,
|
||||
};
|
||||
}
|
||||
|
||||
if (isTextElement(element)) {
|
||||
const nextFont = measureFontSizeFromWH(element, width, height);
|
||||
if (nextFont === null) {
|
||||
return null;
|
||||
}
|
||||
font = { fontSize: nextFont.size, baseline: nextFont.baseline };
|
||||
}
|
||||
const origCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
const rescaledPoints = rescalePointsInElement(element, width, height);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width, height },
|
||||
simultaneouslyUpdated: elements,
|
||||
});
|
||||
|
||||
const finalCoords = getResizedElementAbsoluteCoords(
|
||||
{
|
||||
...element,
|
||||
...rescaledPoints,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
const { x, y } = getNextXY(element, origCoords, finalCoords);
|
||||
return [...prev, { width, height, x, y, ...rescaledPoints, ...font }];
|
||||
},
|
||||
[] as
|
||||
| {
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
points?: (readonly [number, number])[];
|
||||
fontSize?: number;
|
||||
baseline?: number;
|
||||
}[]
|
||||
| null,
|
||||
);
|
||||
if (updates) {
|
||||
elements.forEach((element, index) => {
|
||||
mutateElement(element, updates[index]);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
|
||||
if (boundTextElement) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: updates[index].fontSize,
|
||||
baseline: updates[index].baseline,
|
||||
});
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutateElement(element.latest, update);
|
||||
|
||||
if (boundTextElement && boundTextUpdates) {
|
||||
mutateElement(boundTextElement, boundTextUpdates);
|
||||
handleBindTextResize(element.latest, transformHandleType);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
|
@@ -1,49 +1,51 @@
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toEqual(149);
|
||||
expect(height).toEqual(0);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(140);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toEqual(200);
|
||||
expect(height).toEqual(0);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(100);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(208);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toEqual(135);
|
||||
expect(height).toEqual(135);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toEqual(0);
|
||||
expect(height).toEqual(0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toEqual(120);
|
||||
expect(height).toEqual(120);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -37,9 +37,7 @@ export const getPerfectElementSize = (
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} else {
|
||||
height =
|
||||
Math.round(absWidth * Math.tan(lockedAngle)) * Math.sign(height) ||
|
||||
height;
|
||||
height = absWidth * Math.tan(lockedAngle) * Math.sign(height) || height;
|
||||
}
|
||||
} else if (elementType !== "selection") {
|
||||
height = absWidth * Math.sign(height);
|
||||
@@ -47,6 +45,46 @@ export const getPerfectElementSize = (
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const getLockedLinearCursorAlignSize = (
|
||||
originX: number,
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
} else if (lockedAngle === Math.PI / 2) {
|
||||
width = 0;
|
||||
} else {
|
||||
// locked angle line, y = mx + b => mx - y + b = 0
|
||||
const a1 = Math.tan(lockedAngle);
|
||||
const b1 = -1;
|
||||
const c1 = originY - a1 * originX;
|
||||
|
||||
// line through cursor, perpendicular to locked angle line
|
||||
const a2 = -1 / a1;
|
||||
const b2 = -1;
|
||||
const c2 = y - a2 * x;
|
||||
|
||||
// intersection of the two lines above
|
||||
const intersectX = (b1 * c2 - b2 * c1) / (a1 * b2 - a2 * b1);
|
||||
const intersectY = (c1 * a2 - c2 * a1) / (a1 * b2 - a2 * b1);
|
||||
|
||||
// delta
|
||||
width = intersectX - originX;
|
||||
height = intersectY - originY;
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export const resizePerfectLineForNWHandler = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
|
@@ -1,9 +1,15 @@
|
||||
import { ExcalidrawElement, PointerType } from "./types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
PointerType,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, Bounds } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import { Zoom } from "../types";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
| "n"
|
||||
@@ -59,8 +65,6 @@ const OMIT_SIDES_FOR_LINE_BACKSLASH = {
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
ne: true,
|
||||
sw: true,
|
||||
};
|
||||
|
||||
const generateTransformHandle = (
|
||||
@@ -82,6 +86,7 @@ export const getTransformHandlesFromCoords = (
|
||||
zoom: Zoom,
|
||||
pointerType: PointerType,
|
||||
omitSides: { [T in TransformHandleType]?: boolean } = {},
|
||||
margin = 4,
|
||||
): TransformHandles => {
|
||||
const size = transformHandleSizes[pointerType];
|
||||
const handleWidth = size / zoom.value;
|
||||
@@ -94,9 +99,7 @@ export const getTransformHandlesFromCoords = (
|
||||
const height = y2 - y1;
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
const dashedLineMargin = 4 / zoom.value;
|
||||
|
||||
const dashedLineMargin = margin / zoom.value;
|
||||
const centeringOffset = (size - 8) / (2 * zoom.value);
|
||||
|
||||
const transformHandles: TransformHandles = {
|
||||
@@ -230,11 +233,7 @@ export const getTransformHandles = (
|
||||
}
|
||||
|
||||
let omitSides: { [T in TransformHandleType]?: boolean } = {};
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "freedraw"
|
||||
) {
|
||||
if (element.type === "freedraw" || isLinearElement(element)) {
|
||||
if (element.points.length === 2) {
|
||||
// only check the last point because starting point is always (0,0)
|
||||
const [, p1] = element.points;
|
||||
@@ -253,12 +252,33 @@ export const getTransformHandles = (
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
}
|
||||
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING * 3
|
||||
: DEFAULT_SPACING;
|
||||
return getTransformHandlesFromCoords(
|
||||
getElementAbsoluteCoords(element),
|
||||
element.angle,
|
||||
zoom,
|
||||
pointerType,
|
||||
omitSides,
|
||||
dashedLineMargin,
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldShowBoundingBox = (
|
||||
elements: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
if (elements.length > 1) {
|
||||
return true;
|
||||
}
|
||||
const element = elements[0];
|
||||
if (!isLinearElement(element)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return element.points.length > 2;
|
||||
};
|
||||
|
@@ -56,6 +56,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
customData?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {
|
||||
|
@@ -7,6 +7,8 @@ import {
|
||||
import { DEFAULT_VERSION } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { AppState } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
type StorageSizes = { scene: number; total: number };
|
||||
|
||||
const STORAGE_SIZE_TIMEOUT = 500;
|
||||
@@ -20,6 +22,8 @@ const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
|
||||
|
||||
type Props = {
|
||||
setToast: (message: string) => void;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
};
|
||||
const CustomStats = (props: Props) => {
|
||||
const [storageSizes, setStorageSizes] = useState<StorageSizes>({
|
||||
@@ -31,7 +35,7 @@ const CustomStats = (props: Props) => {
|
||||
getStorageSizes((sizes) => {
|
||||
setStorageSizes(sizes);
|
||||
});
|
||||
});
|
||||
}, [props.elements, props.appState]);
|
||||
useEffect(() => () => getStorageSizes.cancel(), []);
|
||||
|
||||
const version = getVersion();
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import polyfill from "../polyfill";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
@@ -83,6 +84,7 @@ import { jotaiStore, useAtomWithInitialValue } from "../jotai";
|
||||
import { reconcileElements } from "./collab/reconciliation";
|
||||
import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
|
||||
|
||||
polyfill();
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
@@ -96,6 +98,7 @@ languageDetector.init({
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
| { isExternalScene: true; id: string; key: string }
|
||||
@@ -180,8 +183,28 @@ const initializeScene = async (opts: {
|
||||
}
|
||||
|
||||
if (roomLinkData) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
||||
return {
|
||||
scene: await opts.collabAPI.startCollaboration(roomLinkData),
|
||||
// when collaborating, the state may have already been updated at this
|
||||
// point (we may have received updates from other clients), so reconcile
|
||||
// elements and appState with existing state
|
||||
scene: {
|
||||
...scene,
|
||||
appState: {
|
||||
...restoreAppState(scene?.appState, excalidrawAPI.getAppState()),
|
||||
// necessary if we're invoking from a hashchange handler which doesn't
|
||||
// go through App.initializeScene() that resets this flag
|
||||
isLoading: false,
|
||||
},
|
||||
elements: reconcileElements(
|
||||
scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
},
|
||||
isExternalScene: true,
|
||||
id: roomLinkData.roomId,
|
||||
key: roomLinkData.roomKey,
|
||||
@@ -335,23 +358,9 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
};
|
||||
|
||||
initializeScene({ collabAPI }).then(async (data) => {
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
|
||||
loadImages(data, /* isInitialLoad */ true);
|
||||
|
||||
initialStatePromiseRef.current.promise.resolve({
|
||||
...data.scene,
|
||||
// at this point the state may have already been updated (e.g. when
|
||||
// collaborating, we may have received updates from other clients)
|
||||
appState: restoreAppState(
|
||||
data.scene?.appState,
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
elements: reconcileElements(
|
||||
data.scene?.elements || [],
|
||||
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
});
|
||||
initialStatePromiseRef.current.promise.resolve(data.scene);
|
||||
});
|
||||
|
||||
const onHashChange = async (event: HashChangeEvent) => {
|
||||
@@ -366,7 +375,7 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
excalidrawAPI.updateScene({ appState: { isLoading: true } });
|
||||
|
||||
initializeScene({ collabAPI }).then((data) => {
|
||||
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
|
||||
loadImages(data);
|
||||
if (data.scene) {
|
||||
excalidrawAPI.updateScene({
|
||||
@@ -657,10 +666,15 @@ const ExcalidrawWrapper = () => {
|
||||
[langCode],
|
||||
);
|
||||
|
||||
const renderCustomStats = () => {
|
||||
const renderCustomStats = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
return (
|
||||
<CustomStats
|
||||
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./excalidraw-app";
|
||||
|
||||
import "./excalidraw-app/pwa";
|
||||
import "./excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
|
||||
|
||||
ReactDOM.render(<ExcalidrawApp />, document.getElementById("root"));
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
@@ -80,5 +80,5 @@ export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.shiftKey;
|
||||
|
||||
export const shouldRotateWithDiscreteAngle = (
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
|
||||
) => event.shiftKey;
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "تعذر استيراد المشهد من عنوان URL المتوفر. إما أنها مشوهة، أو لا تحتوي على بيانات Excalidraw JSON صالحة.",
|
||||
"resetLibrary": "هذا سوف يمسح مكتبتك. هل أنت متأكد؟",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} عنصر (عناصر) من المكتبة؟",
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "مفتاح التشفير يجب أن يكون من 22 حرفاً. التعاون المباشر معطل."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع الملف غير مدعوم.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Този файлов формат не се поддържа.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -108,7 +108,7 @@
|
||||
"decreaseFontSize": "Redueix la mida de la lletra",
|
||||
"increaseFontSize": "Augmenta la mida de la lletra",
|
||||
"unbindText": "Desvincular el text",
|
||||
"bindText": "",
|
||||
"bindText": "Ajusta el text al contenidor",
|
||||
"link": {
|
||||
"edit": "Edita l'enllaç",
|
||||
"create": "Crea un enllaç",
|
||||
@@ -121,12 +121,12 @@
|
||||
"unlockAll": "Desbloca-ho tot"
|
||||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": ""
|
||||
"sidebarLock": "Manté la barra lateral oberta"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
"noItems": "Encara no s'hi han afegit elements...",
|
||||
"hint_emptyLibrary": "Trieu un element o un llenç per a afegir-lo aquí, o instal·leu una biblioteca del repositori públic, més avall.",
|
||||
"hint_emptyPrivateLibrary": "Trieu un element o un llenç per a afegir-lo aquí."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Neteja el llenç",
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "No s'ha pogut importar l'escena des de l'adreça URL proporcionada. Està malformada o no conté dades Excalidraw JSON vàlides.",
|
||||
"resetLibrary": "Això buidarà la biblioteca. N'esteu segur?",
|
||||
"removeItemsFromsLibrary": "Suprimir {{count}} element(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "La clau d'encriptació ha de tenir 22 caràcters. La col·laboració en directe està desactivada."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipus de fitxer no suportat.",
|
||||
@@ -196,7 +195,7 @@
|
||||
"fileTooBig": "El fitxer és massa gros. La mida màxima permesa és {{maxSize}}.",
|
||||
"svgImageInsertError": "No ha estat possible inserir la imatge SVG. Les marques SVG semblen invàlides.",
|
||||
"invalidSVGString": "SVG no vàlid.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"cannotResolveCollabServer": "No ha estat possible connectar amb el servidor collab. Si us plau recarregueu la pàgina i torneu a provar.",
|
||||
"importLibraryError": "No s'ha pogut carregar la biblioteca"
|
||||
},
|
||||
"toolBar": {
|
||||
@@ -307,7 +306,7 @@
|
||||
"view": "Visualització",
|
||||
"zoomToFit": "Zoom per veure tots els elements",
|
||||
"zoomToSelection": "Zoom per veure la selecció",
|
||||
"toggleElementLock": ""
|
||||
"toggleElementLock": "Blocar/desblocar la selecció"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Neteja el llenç"
|
||||
@@ -325,7 +324,7 @@
|
||||
"authorName": "Nom o usuari",
|
||||
"libraryName": "Nom de la vostra biblioteca",
|
||||
"libraryDesc": "Descripció de la biblioteca per a ajudar a la gent a entendre'n el funcionament",
|
||||
"githubHandle": "",
|
||||
"githubHandle": "Identificador de GitHub (opcional), per tal que pugueu editar la biblioteca una vegada enviada per a ser revisada",
|
||||
"twitterHandle": "Usuari de twitter (opcional), per tal que puguem donar-vos crèdit quan fem la promoció a Twitter",
|
||||
"website": "Enllaç al vostre lloc web personal o a qualsevol altre (opcional)"
|
||||
},
|
||||
|
@@ -51,10 +51,10 @@
|
||||
"medium": "Střední",
|
||||
"large": "Velké",
|
||||
"veryLarge": "Velmi velké",
|
||||
"solid": "",
|
||||
"solid": "Plný",
|
||||
"hachure": "",
|
||||
"crossHatch": "",
|
||||
"thin": "",
|
||||
"thin": "Tenký",
|
||||
"bold": "",
|
||||
"left": "",
|
||||
"center": "",
|
||||
@@ -68,7 +68,7 @@
|
||||
"canvasColors": "",
|
||||
"canvasBackground": "Pozadí plátna",
|
||||
"drawingCanvas": "",
|
||||
"layers": "",
|
||||
"layers": "Vrstvy",
|
||||
"actions": "",
|
||||
"language": "",
|
||||
"liveCollaboration": "",
|
||||
@@ -87,16 +87,16 @@
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"alignTop": "Zarovnat nahoru",
|
||||
"alignBottom": "Zarovnat dolů",
|
||||
"alignLeft": "Zarovnat vlevo",
|
||||
"alignRight": "Zarovnejte vpravo",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"flipHorizontal": "Převrátit vodorovně",
|
||||
"flipVertical": "Převrátit svisle",
|
||||
"viewMode": "Náhled",
|
||||
"toggleExportColorScheme": "",
|
||||
"share": "Sdílet",
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Die Szene konnte nicht von der angegebenen URL importiert werden. Sie ist entweder fehlerhaft oder enthält keine gültigen Excalidraw JSON-Daten.",
|
||||
"resetLibrary": "Dieses löscht deine Bibliothek. Bist du sicher?",
|
||||
"removeItemsFromsLibrary": "{{count}} Element(e) aus der Bibliothek löschen?",
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert.",
|
||||
"browserZoom": "Die Zoomstufe Deines Browsers ist nicht auf 100% gesetzt, was dazu führen kann, dass der Zeichenbereich falsch angezeigt wird"
|
||||
"invalidEncryptionKey": "Verschlüsselungsschlüssel muss 22 Zeichen lang sein. Die Live-Zusammenarbeit ist deaktiviert."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nicht unterstützter Dateityp.",
|
||||
|
@@ -9,7 +9,7 @@
|
||||
"copy": "Αντιγραφή",
|
||||
"copyAsPng": "Αντιγραφή στο πρόχειρο ως PNG",
|
||||
"copyAsSvg": "Αντιγραφή στο πρόχειρο ως SVG",
|
||||
"copyText": "",
|
||||
"copyText": "Αντιγραφή στο πρόχειρο ως κείμενο",
|
||||
"bringForward": "Στο προσκήνιο",
|
||||
"sendToBack": "Ένα επίπεδο πίσω",
|
||||
"bringToFront": "Ένα επίπεδο μπροστά",
|
||||
@@ -41,7 +41,7 @@
|
||||
"fontFamily": "Γραμματοσειρά",
|
||||
"onlySelected": "Μόνο τα Επιλεγμένα",
|
||||
"withBackground": "Φόντο",
|
||||
"exportEmbedScene": "",
|
||||
"exportEmbedScene": "Ενσωμάτωση σκηνής",
|
||||
"exportEmbedScene_details": "Τα δεδομένα σκηνής θα αποθηκευτούν στο αρχείο PNG/SVG προς εξαγωγή ώστε η σκηνή να είναι δυνατό να αποκατασταθεί από αυτό.\nΘα αυξήσει το μέγεθος του αρχείου προς εξαγωγή.",
|
||||
"addWatermark": "Προσθήκη \"Φτιαγμένο με Excalidraw\"",
|
||||
"handDrawn": "Σχεδιασμένο στο χέρι",
|
||||
@@ -65,7 +65,7 @@
|
||||
"cartoonist": "Σκιτσογράφος",
|
||||
"fileTitle": "Όνομα αρχείου",
|
||||
"colorPicker": "Επιλογή Χρώματος",
|
||||
"canvasColors": "",
|
||||
"canvasColors": "Χρησιμοποείται στον καμβά",
|
||||
"canvasBackground": "Φόντο καμβά",
|
||||
"drawingCanvas": "Σχεδίαση καμβά",
|
||||
"layers": "Στρώματα",
|
||||
@@ -105,28 +105,28 @@
|
||||
"toggleTheme": "Εναλλαγή θέματος",
|
||||
"personalLib": "Προσωπική Βιβλιοθήκη",
|
||||
"excalidrawLib": "Βιβλιοθήκη Excalidraw",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"decreaseFontSize": "Μείωση μεγέθους γραμματοσειράς",
|
||||
"increaseFontSize": "Αύξηση μεγέθους γραμματοσειράς",
|
||||
"unbindText": "Αποσύνδεση κειμένου",
|
||||
"bindText": "Δέσμευση κειμένου στο δοχείο",
|
||||
"link": {
|
||||
"edit": "",
|
||||
"create": "",
|
||||
"label": ""
|
||||
"edit": "Επεξεργασία συνδέσμου",
|
||||
"create": "Δημιουργία συνδέσμου",
|
||||
"label": "Σύνδεσμος"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
"lock": "Κλείδωμα",
|
||||
"unlock": "Ξεκλείδωμα",
|
||||
"lockAll": "Κλείδωμα όλων",
|
||||
"unlockAll": "Ξεκλείδωμα όλων"
|
||||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": ""
|
||||
"statusPublished": "Δημοσιευμένο",
|
||||
"sidebarLock": "Κρατήστε την πλαϊνή μπάρα ανοιχτή"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
"noItems": "Δεν έχουν προστεθεί αντικείμενα ακόμη...",
|
||||
"hint_emptyLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ, ή εγκαταστήστε μια βιβλιοθήκη από το δημόσιο αποθετήριο, παρακάτω.",
|
||||
"hint_emptyPrivateLibrary": "Επιλέξτε ένα στοιχείο στον καμβά για να το προσθέσετε εδώ."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Επαναφορά του καμβά",
|
||||
@@ -174,7 +174,7 @@
|
||||
"couldNotLoadInvalidFile": "Δεν μπόρεσε να ανοίξει εσφαλμένο αρχείο",
|
||||
"importBackendFailed": "Η εισαγωγή από το backend απέτυχε.",
|
||||
"cannotExportEmptyCanvas": "Δεν είναι δυνατή η εξαγωγή κενού καμβά.",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"couldNotCopyToClipboard": "Αδυναμία αντιγραφής στο πρόχειρο.",
|
||||
"decryptFailed": "Δεν ήταν δυνατή η αποκρυπτογράφηση δεδομένων.",
|
||||
"uploadedSecurly": "Η μεταφόρτωση έχει εξασφαλιστεί με κρυπτογράφηση από άκρο σε άκρο, πράγμα που σημαίνει ότι ο διακομιστής Excalidraw και τρίτα μέρη δεν μπορούν να διαβάσουν το περιεχόμενο.",
|
||||
"loadSceneOverridePrompt": "Η φόρτωση εξωτερικού σχεδίου θα αντικαταστήσει το υπάρχον περιεχόμενο. Επιθυμείτε να συνεχίσετε;",
|
||||
@@ -182,22 +182,21 @@
|
||||
"errorAddingToLibrary": "Αδυναμία προσθήκης αντικειμένου στη βιβλιοθήκη",
|
||||
"errorRemovingFromLibrary": "Αδυναμία αφαίρεσης αντικειμένου από τη βιβλιοθήκη",
|
||||
"confirmAddLibrary": "Αυτό θα προσθέσει {{numShapes}} σχήμα(τα) στη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"imageDoesNotContainScene": "",
|
||||
"imageDoesNotContainScene": "Αυτή η εικόνα δεν φαίνεται να περιέχει δεδομένα σκηνής. Έχετε ενεργοποιήσει την ενσωμάτωση σκηνής κατά την εξαγωγή;",
|
||||
"cannotRestoreFromImage": "Η σκηνή δεν ήταν δυνατό να αποκατασταθεί από αυτό το αρχείο εικόνας",
|
||||
"invalidSceneUrl": "",
|
||||
"invalidSceneUrl": "Δεν ήταν δυνατή η εισαγωγή σκηνής από το URL που δώσατε. Είτε έχει λάθος μορφή, είτε δεν περιέχει έγκυρα δεδομένα JSON Excalidraw.",
|
||||
"resetLibrary": "Αυτό θα καθαρίσει τη βιβλιοθήκη σας. Είστε σίγουροι;",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη.",
|
||||
"browserZoom": ""
|
||||
"removeItemsFromsLibrary": "Διαγραφή {{count}} αντικειμένου(ων) από τη βιβλιοθήκη;",
|
||||
"invalidEncryptionKey": "Το κλειδί κρυπτογράφησης πρέπει να είναι 22 χαρακτήρες. Η ζωντανή συνεργασία είναι απενεργοποιημένη."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Μη υποστηριζόμενος τύπος αρχείου.",
|
||||
"imageInsertError": "Αδυναμία εισαγωγής εικόνας. Προσπαθήστε ξανά αργότερα...",
|
||||
"fileTooBig": "Το αρχείο είναι πολύ μεγάλο. Το μέγιστο επιτρεπόμενο μέγεθος είναι {{maxSize}}.",
|
||||
"svgImageInsertError": "",
|
||||
"svgImageInsertError": "Αδυναμία εισαγωγής εικόνας SVG. Η σήμανση της SVG δεν φαίνεται έγκυρη.",
|
||||
"invalidSVGString": "Μη έγκυρο SVG.",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": ""
|
||||
"cannotResolveCollabServer": "Αδυναμία σύνδεσης με τον διακομιστή συνεργασίας. Παρακαλώ ανανεώστε τη σελίδα και προσπαθήστε ξανά.",
|
||||
"importLibraryError": "Αδυναμία φόρτωσης βιβλιοθήκης"
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Επιλογή",
|
||||
@@ -212,8 +211,8 @@
|
||||
"library": "Βιβλιοθήκη",
|
||||
"lock": "Κράτησε επιλεγμένο το εργαλείο μετά το σχέδιο",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": ""
|
||||
"link": "Προσθήκη/ Ενημέρωση συνδέσμου για ένα επιλεγμένο σχήμα",
|
||||
"eraser": "Γόμα"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Ενέργειες καμβά",
|
||||
@@ -230,16 +229,16 @@
|
||||
"linearElementMulti": "Κάνε κλικ στο τελευταίο σημείο ή πάτησε Escape ή Enter για να τελειώσεις",
|
||||
"lockAngle": "Μπορείτε να περιορίσετε τη γωνία κρατώντας πατημένο το SHIFT",
|
||||
"resize": "Μπορείς να περιορίσεις τις αναλογίες κρατώντας το SHIFT ενώ αλλάζεις μέγεθος,\nκράτησε πατημένο το ALT για αλλαγή μεγέθους από το κέντρο",
|
||||
"resizeImage": "",
|
||||
"resizeImage": "Μπορείτε να αλλάξετε το μέγεθος ελεύθερα κρατώντας πατημένο το SHIFT,\nκρατήστε πατημένο το ALT για να αλλάξετε το μέγεθος από το κέντρο",
|
||||
"rotate": "Μπορείς να περιορίσεις τις γωνίες κρατώντας πατημένο το πλήκτρο SHIFT κατά την περιστροφή",
|
||||
"lineEditor_info": "Διπλό-κλικ ή πιέστε Enter για να επεξεργαστείτε τα σημεία",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"lineEditor_pointSelected": "Πατήστε Διαγραφή για αφαίρεση σημείου(ων),\nCtrlOrCmd+D για αντιγραφή, ή σύρετε για μετακίνηση",
|
||||
"lineEditor_nothingSelected": "Επιλέξτε ένα σημείο για να επεξεργαστείτε (κρατήστε πατημένο το SHIFT για να επιλέξετε πολλαπλά),\nή κρατήστε πατημένο το Alt και κάντε κλικ για να προσθέσετε νέα σημεία",
|
||||
"placeImage": "Κάντε κλικ για να τοποθετήσετε την εικόνα ή κάντε κλικ και σύρετε για να ορίσετε το μέγεθός της χειροκίνητα",
|
||||
"publishLibrary": "Δημοσιεύστε τη δική σας βιβλιοθήκη",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": ""
|
||||
"bindTextToElement": "Πατήστε Enter για προσθήκη κειμένου",
|
||||
"deepBoxSelect": "Κρατήστε πατημένο το CtrlOrCmd για να επιλέξετε βαθιά, και να αποτρέψετε τη μεταφορά",
|
||||
"eraserRevert": "Κρατήστε πατημένο το Alt για να επαναφέρετε τα στοιχεία που σημειώθηκαν για διαγραφή"
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "Αδυναμία εμφάνισης προεπισκόπησης",
|
||||
@@ -267,39 +266,39 @@
|
||||
"desc_inProgressIntro": "Η ζωντανή συνεργασία με άλλους είναι σε ενεργή.",
|
||||
"desc_shareLink": "Μοιραστείτε τον σύνδεσμο με όποιον θέλετε να δουλέψετε μαζί:",
|
||||
"desc_exitSession": "Η διακοπή θα σας αποσυνδέσει από το δωμάτιο, αλλά θα μπορείτε να συνεχίσετε να δουλεύετε στον πίνακα, τοπικά. Σημειώσατε ότι αυτό δεν θα επηρεάσει τον πίνακα άλλων, και θα μπορούν ακόμα να συνεισφέρουν στην δική τους έκδοση.",
|
||||
"shareTitle": ""
|
||||
"shareTitle": "Συμμετάσχετε σε μια ζωντανή συνεδρία συνεργασίας για το Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Σφάλμα"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "Αποθήκευση στο δίσκο",
|
||||
"disk_details": "",
|
||||
"disk_details": "Εξαγωγή δεδομένων σκηνής σε ένα αρχείο από το οποίο μπορείτε να εισάγετε αργότερα.",
|
||||
"disk_button": "Αποθήκευση σε αρχείο",
|
||||
"link_title": "Κοινόχρηστος σύνδεσμος",
|
||||
"link_details": "Εξαγωγή ως σύνδεσμο μόνο για ανάγνωση.",
|
||||
"link_button": "Εξαγωγή σε Σύνδεση",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_description": "Αποθηκεύστε τη σκηνή στο χώρο εργασίας σας Excalidraw+.",
|
||||
"excalidrawplus_button": "Εξαγωγή",
|
||||
"excalidrawplus_exportError": ""
|
||||
"excalidrawplus_exportError": "Δεν ήταν δυνατή η εξαγωγή στο Excalidraw+ αυτή τη στιγμή..."
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "Διαβάστε το Blog μας",
|
||||
"click": "κλικ",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"deepSelect": "Βαθιά επιλογή",
|
||||
"deepBoxSelect": "Βαθιά επιλογή μέσα στο πλαίσιο και αποτροπή συρσίματος",
|
||||
"curvedArrow": "Κυρτό βέλος",
|
||||
"curvedLine": "Κυρτή γραμμή",
|
||||
"documentation": "Εγχειρίδιο",
|
||||
"doubleClick": "διπλό κλικ",
|
||||
"drag": "σύρε",
|
||||
"editor": "Επεξεργαστής",
|
||||
"editSelectedShape": "",
|
||||
"editSelectedShape": "Επεξεργασία επιλεγμένου σχήματος (κείμενο/βέλος/γραμμή)",
|
||||
"github": "Βρήκατε πρόβλημα; Υποβάλετε το",
|
||||
"howto": "Ακολουθήστε τους οδηγούς μας",
|
||||
"or": "ή",
|
||||
"preventBinding": "Αποτροπή δέσμευσης βέλων",
|
||||
"tools": "",
|
||||
"tools": "Εργαλεία",
|
||||
"shortcuts": "Συντομεύσεις πληκτρολογίου",
|
||||
"textFinish": "Ολοκλήρωση επεξεργασίας (επεξεργαστής κειμένου)",
|
||||
"textNewLine": "Προσθήκη νέας γραμμής (επεξεργαστής κειμένου)",
|
||||
@@ -307,54 +306,54 @@
|
||||
"view": "Προβολή",
|
||||
"zoomToFit": "Zoom ώστε να χωρέσουν όλα τα στοιχεία",
|
||||
"zoomToSelection": "Ζουμ στην επιλογή",
|
||||
"toggleElementLock": ""
|
||||
"toggleElementLock": "Κλείδωμα/Ξεκλείδωμα επιλογής"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Καθαρισμός καμβά"
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"title": "Δημοσίευση βιβλιοθήκης",
|
||||
"itemName": "Όνομα αντικειμένου",
|
||||
"authorName": "Όνομα δημιουργού",
|
||||
"githubUsername": "GitHub username",
|
||||
"twitterUsername": "Twitter username",
|
||||
"libraryName": "Όνομα βιβλιοθήκης",
|
||||
"libraryDesc": "",
|
||||
"libraryDesc": "Περιγραφή βιβλιοθήκης",
|
||||
"website": "Ιστοσελίδα",
|
||||
"placeholder": {
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
"authorName": "Όνομα ή όνομα χρήστη",
|
||||
"libraryName": "Όνομα της βιβλιοθήκης σας",
|
||||
"libraryDesc": "Περιγραφή της βιβλιοθήκης σας ώστε να βοηθήσει το κοινό να κατανοήσει τη χρήση της",
|
||||
"githubHandle": "Όνομα χρήστη στο GitHub (προαιρετικό), ώστε να μπορείτε να επεξεργαστείτε τη βιβλιοθήκη αφού υποβληθεί για αξιολόγηση",
|
||||
"twitterHandle": "Όνομα χρήστη Twitter (προαιρετικό), ώστε να γνωρίζουμε σε ποιον/η να δώσουμε εύσημα κατά την προώθηση μέσω Twitter",
|
||||
"website": "Σύνδεσμος για την προσωπική σας ιστοσελίδα ή αλλού (προαιρετικό)"
|
||||
},
|
||||
"errors": {
|
||||
"required": "Απαιτείται",
|
||||
"website": "Εισάγετε μια έγκυρη διεύθυνση URL"
|
||||
},
|
||||
"noteDescription": {
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
"pre": "Υποβάλετε τη βιβλιοθήκη σας για να συμπεριληφθεί στο ",
|
||||
"link": "δημόσιο αποθετήριο βιβλιοθήκης",
|
||||
"post": "ώστε να χρησιμοποιηθεί από άλλα άτομα στα σχέδιά τους."
|
||||
},
|
||||
"noteGuidelines": {
|
||||
"pre": "",
|
||||
"pre": "Η βιβλιοθήκη πρέπει πρώτα να εγκριθεί χειροκίνητα. Παρακαλώ διαβάστε τους ",
|
||||
"link": "οδηγίες",
|
||||
"post": ""
|
||||
"post": " πριν την υποβολή. Θα χρειαστείτε έναν λογαριασμό GitHub για την επικοινωνία και για να προβείτε σε αλλαγές εφ' όσον χρειαστεί, αλλά δεν είναι αυστηρή απαίτηση."
|
||||
},
|
||||
"noteLicense": {
|
||||
"pre": "",
|
||||
"link": "",
|
||||
"post": ""
|
||||
"pre": "Με την υποβολή, συμφωνείτε ότι η βιβλιοθήκη θα δημοσιευθεί υπό την ",
|
||||
"link": "Άδεια MIT, ",
|
||||
"post": "που εν συντομία σημαίνει ότι ο καθένας μπορεί να τα χρησιμοποιήσει χωρίς περιορισμούς."
|
||||
},
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"noteItems": "Κάθε αντικείμενο της βιβλιοθήκης πρέπει να έχει το δικό του όνομα ώστε να μπορεί να φιλτραριστεί. Θα συμπεριληφθούν τα ακόλουθα αντικείμενα βιβλιοθήκης:",
|
||||
"atleastOneLibItem": "Παρακαλώ επιλέξτε τουλάχιστον ένα αντικείμενο βιβλιοθήκης για να ξεκινήσετε",
|
||||
"republishWarning": "Σημείωση: μερικά από τα επιλεγμένα αντικέιμενα έχουν ήδη επισημανθεί ως δημοσιευμένα/υποβεβλημένα. Θα πρέπει να υποβάλετε αντικείμενα εκ νέου μόνο για να ενημερώσετε μία ήδη υπάρχουσα βιβλιοθήκη ή υποβολή."
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
"content": "",
|
||||
"title": "Η βιβλιοθήκη υποβλήθηκε",
|
||||
"content": "Ευχαριστούμε {{authorName}}. Η βιβλιοθήκη σας έχει υποβληθεί για αξιολόγηση. Μπορείτε να παρακολουθείτε τη διαδικασία",
|
||||
"link": "εδώ"
|
||||
},
|
||||
"confirmDialog": {
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Couldn't import scene from the supplied URL. It's either malformed, or doesn't contain valid Excalidraw JSON data.",
|
||||
"resetLibrary": "This will clear your library. Are you sure?",
|
||||
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
|
||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
|
||||
"browserZoom": "Your browser's zoom level is not set to 100% which may cause the board to display incorrectly"
|
||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Unsupported file type.",
|
||||
|
@@ -124,7 +124,7 @@
|
||||
"sidebarLock": "Mantener barra lateral abierta"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"noItems": "No hay elementos añadidos todavía...",
|
||||
"hint_emptyLibrary": "Seleccione un elemento en el lienzo para añadirlo aquí, o instale una biblioteca del repositorio público, a continuación.",
|
||||
"hint_emptyPrivateLibrary": "Seleccione un elemento del lienzo para añadirlo aquí."
|
||||
},
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "No se ha podido importar la escena desde la URL proporcionada. Está mal formada, o no contiene datos de Excalidraw JSON válidos.",
|
||||
"resetLibrary": "Esto borrará tu biblioteca. ¿Estás seguro?",
|
||||
"removeItemsFromsLibrary": "¿Eliminar {{count}} elemento(s) de la biblioteca?",
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada.",
|
||||
"browserZoom": "El nivel de zoom de tu navegador no está configurado al 100%, lo que puede causar que el tablero se muestre de manera incorrecta"
|
||||
"invalidEncryptionKey": "La clave de cifrado debe tener 22 caracteres. La colaboración en vivo está deshabilitada."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo de archivo no admitido.",
|
||||
|
@@ -121,12 +121,12 @@
|
||||
"unlockAll": "Desblokeatu guztiak"
|
||||
},
|
||||
"statusPublished": "Argitaratua",
|
||||
"sidebarLock": ""
|
||||
"sidebarLock": "Mantendu alboko barra irekita"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
"noItems": "Oraindik ez da elementurik gehitu...",
|
||||
"hint_emptyLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko, edo instalatu liburutegi bat beheko biltegi publikotik.",
|
||||
"hint_emptyPrivateLibrary": "Hautatu oihaleko elementu bat hemen gehitzeko."
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "Garbitu oihala",
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Ezin izan da eszena inportatu emandako URLtik. Gaizki eratuta dago edo ez du baliozko Excalidraw JSON daturik.",
|
||||
"resetLibrary": "Honek zure liburutegia garbituko du. Ziur zaude?",
|
||||
"removeItemsFromsLibrary": "Liburutegitik {{count}} elementu ezabatu?",
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "Enkriptazio-gakoak 22 karaktere izan behar ditu. Zuzeneko lankidetza desgaituta dago."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Onartu gabeko fitxategi mota.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "بوم نقاشی از آدرس ارائه شده وارد نشد. این یا نادرست است، یا حاوی داده Excalidraw JSON معتبر نیست.",
|
||||
"resetLibrary": "ین کار کل صفحه را پاک میکند. آیا مطمئنید?",
|
||||
"removeItemsFromsLibrary": "حذف {{count}} آیتم(ها) از کتابخانه?",
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "کلید رمزگذاری باید 22 کاراکتر باشد. همکاری زنده غیرفعال است."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "نوع فایل پشتیبانی نشده.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Teosta ei voitu tuoda annetusta URL-osoitteesta. Tallenne on vioittunut, tai osoitteessa ei ole Excalidraw JSON-dataa.",
|
||||
"resetLibrary": "Tämä tyhjentää kirjastosi. Jatketaanko?",
|
||||
"removeItemsFromsLibrary": "Poista {{count}} kohdetta kirjastosta?",
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "Salausavaimen on oltava 22 merkkiä pitkä. Live-yhteistyö ei ole käytössä."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tiedostotyyppiä ei tueta.",
|
||||
|
@@ -10,29 +10,29 @@
|
||||
"copyAsPng": "Copier dans le presse-papier en PNG",
|
||||
"copyAsSvg": "Copier dans le presse-papier en SVG",
|
||||
"copyText": "Copier dans le presse-papier en tant que texte",
|
||||
"bringForward": "Envoyer vers l'avant",
|
||||
"sendToBack": "Mettre en arrière-plan",
|
||||
"bringToFront": "Mettre au premier plan",
|
||||
"sendBackward": "Envoyer vers l'arrière",
|
||||
"bringForward": "Avancer d'un plan",
|
||||
"sendToBack": "Déplacer à l'arrière-plan",
|
||||
"bringToFront": "Placer au premier plan",
|
||||
"sendBackward": "Reculer d'un plan",
|
||||
"delete": "Supprimer",
|
||||
"copyStyles": "Copier les styles",
|
||||
"pasteStyles": "Coller les styles",
|
||||
"stroke": "Trait",
|
||||
"background": "Arrière-plan",
|
||||
"fill": "Remplissage",
|
||||
"strokeWidth": "Largeur du trait",
|
||||
"background": "Fond",
|
||||
"fill": "Motif du fond",
|
||||
"strokeWidth": "Épaisseur du trait",
|
||||
"strokeStyle": "Style du trait",
|
||||
"strokeStyle_solid": "Plein",
|
||||
"strokeStyle_solid": "Continu",
|
||||
"strokeStyle_dashed": "Tirets",
|
||||
"strokeStyle_dotted": "Pointillé",
|
||||
"strokeStyle_dotted": "Pointillés",
|
||||
"sloppiness": "Style de tracé",
|
||||
"opacity": "Opacité",
|
||||
"textAlign": "Alignement du texte",
|
||||
"edges": "Angles",
|
||||
"sharp": "Pointus",
|
||||
"round": "Arrondis",
|
||||
"arrowheads": "Extrémités de flèche",
|
||||
"arrowhead_none": "Aucune",
|
||||
"arrowheads": "Extrémités",
|
||||
"arrowhead_none": "Sans",
|
||||
"arrowhead_arrow": "Flèche",
|
||||
"arrowhead_bar": "Barre",
|
||||
"arrowhead_dot": "Point",
|
||||
@@ -43,23 +43,23 @@
|
||||
"withBackground": "Arrière-plan",
|
||||
"exportEmbedScene": "Intégrer la scène",
|
||||
"exportEmbedScene_details": "Les données de scène seront enregistrées dans le fichier PNG/SVG exporté, afin que la scène puisse être restaurée à partir de celui-ci.\nCela augmentera la taille du fichier exporté.",
|
||||
"addWatermark": "Ajouter \"Fait avec Excalidraw\"",
|
||||
"addWatermark": "Ajouter \"Réalisé avec Excalidraw\"",
|
||||
"handDrawn": "À la main",
|
||||
"normal": "Normale",
|
||||
"code": "Code",
|
||||
"small": "Petit",
|
||||
"medium": "Moyen",
|
||||
"large": "Grand",
|
||||
"veryLarge": "Très grand",
|
||||
"small": "Petite",
|
||||
"medium": "Moyenne",
|
||||
"large": "Grande",
|
||||
"veryLarge": "Très grande",
|
||||
"solid": "Solide",
|
||||
"hachure": "Hachure",
|
||||
"crossHatch": "Hachure croisée",
|
||||
"thin": "Fin",
|
||||
"bold": "Épais",
|
||||
"left": "Gauche",
|
||||
"center": "Centre",
|
||||
"right": "Droite",
|
||||
"extraBold": "Très épais",
|
||||
"hachure": "Hachures",
|
||||
"crossHatch": "Hachures croisées",
|
||||
"thin": "Fine",
|
||||
"bold": "Épaisse",
|
||||
"left": "À gauche",
|
||||
"center": "Au centre",
|
||||
"right": "À droite",
|
||||
"extraBold": "Très épaisse",
|
||||
"architect": "Architecte",
|
||||
"artist": "Artiste",
|
||||
"cartoonist": "Caricaturiste",
|
||||
@@ -68,7 +68,7 @@
|
||||
"canvasColors": "Utilisé sur la zone de dessin",
|
||||
"canvasBackground": "Arrière-plan du canevas",
|
||||
"drawingCanvas": "Zone de dessin",
|
||||
"layers": "Calques",
|
||||
"layers": "Disposition",
|
||||
"actions": "Actions",
|
||||
"language": "Langue",
|
||||
"liveCollaboration": "Collaboration en direct",
|
||||
@@ -86,22 +86,22 @@
|
||||
"libraryLoadingMessage": "Chargement de la bibliothèque…",
|
||||
"libraries": "Parcourir les bibliothèques",
|
||||
"loadingScene": "Chargement de la scène…",
|
||||
"align": "Aligner",
|
||||
"align": "Alignement",
|
||||
"alignTop": "Aligner en haut",
|
||||
"alignBottom": "Aligner en bas",
|
||||
"alignLeft": "Aligner à gauche",
|
||||
"alignRight": "Aligner à droite",
|
||||
"centerVertically": "Centrer verticalement",
|
||||
"centerHorizontally": "Centrer horizontalement",
|
||||
"distributeHorizontally": "Distribuer horizontalement",
|
||||
"distributeVertically": "Distribuer verticalement",
|
||||
"distributeHorizontally": "Répartir horizontalement",
|
||||
"distributeVertically": "Répartir verticalement",
|
||||
"flipHorizontal": "Retourner horizontalement",
|
||||
"flipVertical": "Retourner verticalement",
|
||||
"viewMode": "Mode présentation",
|
||||
"toggleExportColorScheme": "Activer/Désactiver l'export du thème de couleur",
|
||||
"share": "Partager",
|
||||
"showStroke": "Afficher le sélecteur de couleur de trait",
|
||||
"showBackground": "Afficher le sélecteur de couleur d'arrière-plan",
|
||||
"showBackground": "Afficher le sélecteur de couleur de fond",
|
||||
"toggleTheme": "Changer le thème",
|
||||
"personalLib": "Bibliothèque personnelle",
|
||||
"excalidrawLib": "Bibliothèque Excalidraw",
|
||||
@@ -170,9 +170,9 @@
|
||||
"alerts": {
|
||||
"clearReset": "L'intégralité du canevas va être effacée. Êtes-vous sûr ?",
|
||||
"couldNotCreateShareableLink": "Impossible de créer un lien de partage.",
|
||||
"couldNotCreateShareableLinkTooBig": "Impossible de créer un lien partageable : la scène est trop volumineuse",
|
||||
"couldNotCreateShareableLinkTooBig": "Impossible de créer un lien de partage : la scène est trop volumineuse",
|
||||
"couldNotLoadInvalidFile": "Impossible de charger un fichier invalide",
|
||||
"importBackendFailed": "L'importation depuis le backend a échoué.",
|
||||
"importBackendFailed": "L'importation depuis le serveur a échoué.",
|
||||
"cannotExportEmptyCanvas": "Impossible d'exporter un canevas vide.",
|
||||
"couldNotCopyToClipboard": "Impossible de copier dans le presse-papiers.",
|
||||
"decryptFailed": "Les données n'ont pas pu être déchiffrées.",
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Impossible d'importer la scène depuis l'URL fournie. Elle est soit incorrecte, soit ne contient pas de données JSON Excalidraw valides.",
|
||||
"resetLibrary": "Cela va effacer votre bibliothèque. Êtes-vous sûr·e ?",
|
||||
"removeItemsFromsLibrary": "Supprimer {{count}} élément(s) de la bibliothèque ?",
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée.",
|
||||
"browserZoom": "Le niveau de zoom de votre navigateur n'est pas défini sur 100 %, ce qui peut entraîner un affichage incorrect du tableau"
|
||||
"invalidEncryptionKey": "La clé de chiffrement doit comporter 22 caractères. La collaboration en direct est désactivée."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Type de fichier non supporté.",
|
||||
@@ -229,7 +228,7 @@
|
||||
"text_editing": "Appuyez sur ÉCHAP ou Ctrl/Cmd+ENTRÉE pour terminer l'édition",
|
||||
"linearElementMulti": "Cliquez sur le dernier point ou appuyez sur Échap ou Entrée pour terminer",
|
||||
"lockAngle": "Vous pouvez restreindre l'angle en maintenant MAJ",
|
||||
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement,\nmaintenez la touche ALT pour redimensionner par rapport au centre",
|
||||
"resize": "Vous pouvez conserver les proportions en maintenant la touche MAJ pendant le redimensionnement, maintenez la touche ALT pour redimensionner par rapport au centre",
|
||||
"resizeImage": "Vous pouvez redimensionner librement en maintenant SHIFT,\nmaintenez ALT pour redimensionner depuis le centre",
|
||||
"rotate": "Vous pouvez restreindre les angles en maintenant MAJ pendant la rotation",
|
||||
"lineEditor_info": "Double-cliquez ou appuyez sur Entrée pour éditer les points",
|
||||
@@ -238,7 +237,7 @@
|
||||
"placeImage": "Cliquez pour placer l'image, ou cliquez et faites glisser pour définir sa taille manuellement",
|
||||
"publishLibrary": "Publier votre propre bibliothèque",
|
||||
"bindTextToElement": "Appuyer sur Entrée pour ajouter du texte",
|
||||
"deepBoxSelect": "Maintenir CtrlOuCmd pour sélectionner dans les groupes, et empêcher le déplacement",
|
||||
"deepBoxSelect": "Maintenir Ctrl ou Cmd pour sélectionner dans les groupes et empêcher le déplacement",
|
||||
"eraserRevert": "Maintenez Alt enfoncé pour annuler les éléments marqués pour suppression"
|
||||
},
|
||||
"canvasError": {
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "ייבוא המידע מן סצינה מכתובת האינטרנט נכשלה. המידע בנוי באופן משובש או שהוא אינו קובץ JSON תקין של Excalidraw.",
|
||||
"resetLibrary": "פעולה זו תנקה את כל הלוח. אתה בטוח?",
|
||||
"removeItemsFromsLibrary": "מחיקת {{count}} פריטים(ים) מתוך הספריה?",
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "מפתח ההצפנה חייב להיות בן 22 תוים. השיתוף החי מבוטל."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "סוג הקובץ אינו נתמך.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": "आपके ब्राउज़र का ज़ूम लेवल 100% नहीं हैं इस कारण दृष्य पटल ग़लत दिख सकता हैं"
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Nem sikerült importálni a jelenetet a megadott URL-ről. Rossz formátumú, vagy nem tartalmaz érvényes Excalidraw JSON-adatokat.",
|
||||
"resetLibrary": "Ezzel törlöd a könyvtárát. biztos vagy ebben?",
|
||||
"removeItemsFromsLibrary": "{{count}} elemet törölsz a könyvtárból?",
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "A titkosítási kulcsnak 22 karakterből kell állnia. Az élő együttműködés le van tiltva."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Nem támogatott fájltípus.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Tidak dapat impor pemandangan dari URL. Kemungkinan URL itu rusak atau tidak berisi data JSON Excalidraw yang valid.",
|
||||
"resetLibrary": "Ini akan menghapus pustaka Anda. Anda yakin?",
|
||||
"removeItemsFromsLibrary": "Hapus {{count}} item dari pustaka?",
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan.",
|
||||
"browserZoom": "Pembesaran peramban Anda tidak 100% yang mana dapat menyebabkan layar tidak menampilkan dengan benar"
|
||||
"invalidEncryptionKey": "Sandi enkripsi harus 22 karakter. Kolaborasi langsung dinonaktifkan."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipe file tidak didukung.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Impossibile importare la scena dall'URL fornito. Potrebbe essere malformato o non contenere dati JSON Excalidraw validi.",
|
||||
"resetLibrary": "Questa azione cancellerà l'intera libreria. Sei sicuro?",
|
||||
"removeItemsFromsLibrary": "Eliminare {{count}} elementi dalla libreria?",
|
||||
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata.",
|
||||
"browserZoom": "Il livello di zoom del tuo browser non è impostato al 100%, il che potrebbe causare una visualizzazione scorretta della scheda"
|
||||
"invalidEncryptionKey": "La chiave di cifratura deve essere composta da 22 caratteri. La collaborazione live è disabilitata."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Tipo di file non supportato.",
|
||||
|
@@ -121,12 +121,12 @@
|
||||
"unlockAll": "すべてのロックを解除"
|
||||
},
|
||||
"statusPublished": "公開済み",
|
||||
"sidebarLock": ""
|
||||
"sidebarLock": "サイドバーを開いたままにする"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
"noItems": "まだアイテムが追加されていません…",
|
||||
"hint_emptyLibrary": "キャンバス上のアイテムを選択してここに追加するか、以下の公開リポジトリからライブラリをインストールしてください。",
|
||||
"hint_emptyPrivateLibrary": "キャンバス上のアイテムを選択すると、ここに追加されます。"
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "キャンバスのリセット",
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "指定された URL からシーンをインポートできませんでした。不正な形式であるか、有効な Excalidraw JSON データが含まれていません。",
|
||||
"resetLibrary": "ライブラリを消去します。本当によろしいですか?",
|
||||
"removeItemsFromsLibrary": "{{count}} 個のアイテムをライブラリから削除しますか?",
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "暗号化キーは22文字でなければなりません。ライブコラボレーションは無効化されています。"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "サポートされていないファイル形式です。",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Ulamek taktert n usayes seg URL i d-ittunefken. Ahat mačči d tameɣtut neɣ ur tegbir ara isefka JSON n Excalidraw.",
|
||||
"resetLibrary": "Ayagi ad isfeḍ tamkarḍit-inek•m. Tetḥeqqeḍ?",
|
||||
"removeItemsFromsLibrary": "Ad tekkseḍ {{count}} n uferdis (en) si temkarḍit?",
|
||||
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "Tasarut n uwgelhen isefk ad tesɛu 22 n yiekkilen. Amɛiwen srid yensa."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Anaw n ufaylu ur yettwasefrak ara.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -121,10 +121,10 @@
|
||||
"unlockAll": "모두 잠금 해제"
|
||||
},
|
||||
"statusPublished": "게시됨",
|
||||
"sidebarLock": ""
|
||||
"sidebarLock": "사이드바 유지"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"noItems": "추가된 아이템 없음",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "제공된 URL에서 화면을 가져오는데 실패했습니다. 주소가 잘못되거나, 유효한 Excalidraw JSON 데이터를 포함하고 있지 않은 것일 수 있습니다.",
|
||||
"resetLibrary": "당신의 라이브러리를 초기화 합니다. 계속하시겠습니까?",
|
||||
"removeItemsFromsLibrary": "{{count}}개의 아이템을 라이브러리에서 삭제하시겠습니까?",
|
||||
"invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다.",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": "암호화 키는 반드시 22글자여야 합니다. 실시간 협업이 비활성화됩니다."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "지원하지 않는 파일 형식 입니다.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Nevarēja importēt ainu no norādītā URL. Vai nu tas ir nederīgs, vai nesatur derīgus Excalidraw JSON datus.",
|
||||
"resetLibrary": "Šī funkcija iztukšos bibliotēku. Vai turpināt?",
|
||||
"removeItemsFromsLibrary": "Vai izņemt {{count}} vienumu(s) no bibliotēkas?",
|
||||
"invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta.",
|
||||
"browserZoom": "Pārlūka pietuvināšanas līmenis nav 100%; šis var sakropļot tāfeles izskatu"
|
||||
"invalidEncryptionKey": "Šifrēšanas atslēgai jābūt 22 simbolus garai. Tiešsaistes sadarbība ir izslēgta."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Neatbalstīts datnes veids.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "दिलेल्या यू-आर-एल पासून दृश्य आणू शकलो नाही. तो एकतर बरोबार नाही आहे किंवा त्यात वैध एक्सकेलीड्रॉ जेसन डेटा नाही.",
|
||||
"resetLibrary": "पटल स्वच्छ होणार, तुम्हाला खात्री आहे का?",
|
||||
"removeItemsFromsLibrary": "संग्रहातून {{count}} तत्व (एक किव्हा अनेक) काढू?",
|
||||
"invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे.",
|
||||
"browserZoom": "वेब ब्राउज़र चे ज़ूम लेवल 100% नाही आहे त्या कारणानी पटल चूक दिसू सकतो"
|
||||
"invalidEncryptionKey": "कूटबद्धन कुंजी 22 अक्षरांची असणे आवश्यक आहे. थेट सहयोग अक्षम केले आहे."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "असमर्थित फाइल प्रकार.",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"browserZoom": ""
|
||||
"invalidEncryptionKey": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
|
@@ -187,8 +187,7 @@
|
||||
"invalidSceneUrl": "Kunne ikke importere scene fra den oppgitte URL-en. Den er enten ødelagt, eller inneholder ikke gyldig Excalidraw JSON-data.",
|
||||
"resetLibrary": "Dette vil tømme biblioteket ditt. Er du sikker?",
|
||||
"removeItemsFromsLibrary": "Slett {{count}} element(er) fra biblioteket?",
|
||||
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert.",
|
||||
"browserZoom": "Nettleserens zoomnivå er ikke satt til 100%, som kan føre til at lerretet vises feil"
|
||||
"invalidEncryptionKey": "Krypteringsnøkkel må ha 22 tegn. Live-samarbeid er deaktivert."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Filtypen støttes ikke.",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user