mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
init: the first public commit for AFFiNE
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# components-account
|
||||
|
||||
- components about user accounts
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test components-account` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
displayName: 'components-account',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/account',
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@toeverything/components/account",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@authing/react-ui-components": "^3.1.23",
|
||||
"@emotion/styled": "^11.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"authing-js-sdk": "^4.23.33"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"sourceRoot": "libs/components/account/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/account",
|
||||
"tsConfig": "libs/components/account/tsconfig.lib.json",
|
||||
"project": "libs/components/account/package.json",
|
||||
"entryFile": "libs/components/account/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/account/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/account/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/account"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/account/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import style9 from 'style9';
|
||||
import { getAuth, signOut } from 'firebase/auth';
|
||||
|
||||
import { MuiBox as Box } from '@toeverything/components/ui';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import { LOGOUT_LOCAL_STORAGE, LOGOUT_COOKIES } from '@toeverything/utils';
|
||||
|
||||
const styles = style9.create({
|
||||
container: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: 'calc( 100vh - 64px )',
|
||||
},
|
||||
});
|
||||
|
||||
type ErrorProps = {
|
||||
title?: string;
|
||||
subTitle?: string;
|
||||
action1Text?: string;
|
||||
action1Route?: string;
|
||||
action2Text?: string;
|
||||
action2Route?: string;
|
||||
clearOnClick?: boolean;
|
||||
};
|
||||
|
||||
const floatAnimation = keyframes`
|
||||
100% { transform: translateY(20px) }
|
||||
`;
|
||||
|
||||
/**
|
||||
* Exception related pages
|
||||
*/
|
||||
export function Error({
|
||||
title = 'Page Not Found',
|
||||
subTitle,
|
||||
action1Text,
|
||||
action1Route,
|
||||
action2Text,
|
||||
action2Route,
|
||||
clearOnClick = false,
|
||||
}: ErrorProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: '100vh',
|
||||
backgroundColor: '#2F3242',
|
||||
position: 'relative',
|
||||
'& svg': {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '45%',
|
||||
marginTop: '-250px',
|
||||
marginLeft: '-400px',
|
||||
},
|
||||
'& .message-box': {
|
||||
height: '200px',
|
||||
minWidth: '380px',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '45%',
|
||||
marginTop: '-100px',
|
||||
marginLeft: '0',
|
||||
color: '#FFF',
|
||||
// font-family: Roboto;
|
||||
// font-weight: 300;
|
||||
},
|
||||
'& .message-box h1': {
|
||||
fontSize: '60px',
|
||||
lineHeight: '46px',
|
||||
marginBottom: '40px',
|
||||
color: '#FFF',
|
||||
},
|
||||
'& .message-box h3': {
|
||||
fontSize: '32px',
|
||||
fontWeight: 400,
|
||||
lineHeight: '32px',
|
||||
color: '#FFF',
|
||||
},
|
||||
'& .buttons-con .action-link-wrap': {
|
||||
marginTop: '40px',
|
||||
},
|
||||
'& .buttons-con .action-link-wrap a': {
|
||||
backgroundColor: '#3E6FDB',
|
||||
padding: ' 8px 25px',
|
||||
borderRadius: '4px',
|
||||
color: '#FFF',
|
||||
fontWeight: 'bold',
|
||||
// font-size: 14px;
|
||||
transition: 'all 0.3s linear',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
marginRight: '10px',
|
||||
},
|
||||
'& .buttons-con .action-link-wrap a:hover': {
|
||||
backgroundColor: '#5A5C6C',
|
||||
color: '#fff',
|
||||
},
|
||||
'& #Polygon-1': {
|
||||
animation: 'float 1s infinite ease-in-out alternate',
|
||||
},
|
||||
'& #Polygon-2': {
|
||||
animation: `${floatAnimation} 1s infinite ease-in-out alternate`,
|
||||
animationDelay: '0.2s',
|
||||
},
|
||||
'& #Polygon-3': {
|
||||
animation: `${floatAnimation} 1s infinite ease-in-out alternate`,
|
||||
animationDelay: '0.4s',
|
||||
},
|
||||
'& #Polygon-4': {
|
||||
animation: `${floatAnimation} 1s infinite ease-in-out alternate`,
|
||||
animationDelay: '0.6s',
|
||||
},
|
||||
'& #Polygon-5': {
|
||||
animation: `${floatAnimation} 1s infinite ease-in-out alternate`,
|
||||
animationDelay: '0.8s',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="root">
|
||||
<svg
|
||||
width="380px"
|
||||
height="500px"
|
||||
viewBox="0 0 837 1045"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="Page-1"
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
// sketch:type="MSPage"
|
||||
>
|
||||
<path
|
||||
d="M353,9 L626.664028,170 L626.664028,487 L353,642 L79.3359724,487 L79.3359724,170 L353,9 Z"
|
||||
id="Polygon-1"
|
||||
stroke="#007FB2"
|
||||
strokeWidth="6"
|
||||
// sketch:type="MSShapeGroup"
|
||||
/>
|
||||
<path
|
||||
d="M78.5,529 L147,569.186414 L147,648.311216 L78.5,687 L10,648.311216 L10,569.186414 L78.5,529 Z"
|
||||
id="Polygon-2"
|
||||
stroke="#EF4A5B"
|
||||
strokeWidth="6"
|
||||
// sketch:type="MSShapeGroup"
|
||||
/>
|
||||
<path
|
||||
d="M773,186 L827,217.538705 L827,279.636651 L773,310 L719,279.636651 L719,217.538705 L773,186 Z"
|
||||
id="Polygon-3"
|
||||
stroke="#795D9C"
|
||||
strokeWidth="6"
|
||||
// sketch:type="MSShapeGroup"
|
||||
/>
|
||||
<path
|
||||
d="M639,529 L773,607.846761 L773,763.091627 L639,839 L505,763.091627 L505,607.846761 L639,529 Z"
|
||||
id="Polygon-4"
|
||||
stroke="#F2773F"
|
||||
strokeWidth="6"
|
||||
// sketch:type="MSShapeGroup"
|
||||
/>
|
||||
<path
|
||||
d="M281,801 L383,861.025276 L383,979.21169 L281,1037 L179,979.21169 L179,861.025276 L281,801 Z"
|
||||
id="Polygon-5"
|
||||
stroke="#36B455"
|
||||
strokeWidth="6"
|
||||
// sketch:type="MSShapeGroup"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div className="message-box">
|
||||
<h1>{title}</h1>
|
||||
{subTitle ? <h3>{subTitle}</h3> : null}
|
||||
<div className="buttons-con">
|
||||
<div className="action-link-wrap">
|
||||
{/* {action1Text ? (
|
||||
<Link
|
||||
to={action1Route || '/login'}
|
||||
className="link-button link-back-button"
|
||||
onClick={event => {
|
||||
if (clearOnClick) {
|
||||
event.preventDefault();
|
||||
LOGOUT_LOCAL_STORAGE.forEach(name =>
|
||||
localStorage.removeItem(name)
|
||||
);
|
||||
document.cookie =
|
||||
LOGOUT_COOKIES.map(
|
||||
name =>
|
||||
name +
|
||||
'=; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
|
||||
).join(' ');
|
||||
|
||||
signOut(getAuth());
|
||||
|
||||
window.location.href =
|
||||
action1Route || '/login';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{action1Text}
|
||||
</Link>
|
||||
) : null} */}
|
||||
{/* {action2Text ? (
|
||||
<Link
|
||||
to={action2Route || '/login'}
|
||||
className="link-button link-back-button"
|
||||
>
|
||||
{action2Text}
|
||||
</Link>
|
||||
) : null} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Login } from './login';
|
||||
export { Error } from './error';
|
||||
export { PageLoading } from './loading';
|
||||
@@ -0,0 +1,23 @@
|
||||
import style9 from 'style9';
|
||||
import { MuiCircularProgress as CircularProgress } from '@toeverything/components/ui';
|
||||
|
||||
const styles = style9.create({
|
||||
container: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: 'calc( 100vh - 64px )',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading components occupy the entire page
|
||||
*/
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<div className={styles('container')}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import style9 from 'style9';
|
||||
import { MuiBox as Box } from '@toeverything/components/ui';
|
||||
import { Guard as AuthingGuard } from '@authing/react-ui-components';
|
||||
import type { User as AuthingUser } from 'authing-js-sdk';
|
||||
|
||||
import {
|
||||
AUTHING_APP_ID_US,
|
||||
AUTHING_APP_ID_CN,
|
||||
AUTHING_APP_HOST_US,
|
||||
} from '@toeverything/utils';
|
||||
import { useUserAndSpaces } from '@toeverything/datasource/state';
|
||||
|
||||
import '@authing/react-ui-components/lib/index.min.css';
|
||||
|
||||
export function Authing() {
|
||||
const navigate = useNavigate();
|
||||
const { handleUserLoginSuccess, currentSpaceId } = useUserAndSpaces();
|
||||
|
||||
/**
|
||||
* Refer to Authing documentation https://docs.authing.cn/v2/reference/guard/react.html
|
||||
*/
|
||||
const handle_login_success = useCallback(
|
||||
async (authingUser: AuthingUser) => {
|
||||
handleUserLoginSuccess(authingUser);
|
||||
},
|
||||
[handleUserLoginSuccess]
|
||||
);
|
||||
|
||||
const authing_config = useMemo(() => {
|
||||
return {
|
||||
host: AUTHING_APP_HOST_US,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSpaceId) {
|
||||
const targetRoute = `/${currentSpaceId}`;
|
||||
navigate(targetRoute);
|
||||
}
|
||||
}, [currentSpaceId, navigate]);
|
||||
|
||||
return (
|
||||
<div className={styles('loginContainer')}>
|
||||
<Box
|
||||
sx={{
|
||||
'.g2-view-header': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'.authing-g2-render-module': {
|
||||
boxShadow: '0 2px 10px 0 rgb(57 106 255 / 20%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AuthingGuard
|
||||
onLogin={handle_login_success}
|
||||
appId={AUTHING_APP_ID_US}
|
||||
config={authing_config}
|
||||
/>
|
||||
<Box sx={{ height: '56px' }} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = style9.create({
|
||||
loginContainer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: 'calc( 100vh - 64px )',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/* eslint-disable filename-rules/match */
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { Error } from './../error';
|
||||
import {
|
||||
GoogleAuthProvider,
|
||||
getAuth,
|
||||
signInWithPopup,
|
||||
browserLocalPersistence,
|
||||
} from 'firebase/auth';
|
||||
|
||||
import { LogoImg } from '@toeverything/components/common';
|
||||
import {
|
||||
MuiButton,
|
||||
MuiBox,
|
||||
MuiTypography,
|
||||
MuiContainer,
|
||||
MuiGrid,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
const _firebaseConfig = {
|
||||
apiKey: 'AIzaSyD7A_VyGaKTXsPqtga9IbwrEsbWWc4rH3Y',
|
||||
authDomain: 'login.affine.pro',
|
||||
projectId: 'affine-346417',
|
||||
storageBucket: 'affine-346417.appspot.com',
|
||||
messagingSenderId: '690608236388',
|
||||
appId: '1:690608236388:web:ccc7ee97b59a4cf2677c71',
|
||||
};
|
||||
|
||||
const _app = initializeApp(_firebaseConfig);
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg width="24px" height="24px" viewBox="0 0 118 120">
|
||||
<g
|
||||
id="Page-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="Artboard-1" transform="translate(-332.000000, -639.000000)">
|
||||
<g
|
||||
id="google_buttn"
|
||||
transform="translate(332.000000, 639.000000)"
|
||||
>
|
||||
<g id="logo_googleg_48dp">
|
||||
<path
|
||||
d="M117.6,61.3636364 C117.6,57.1090909 117.218182,53.0181818 116.509091,49.0909091 L60,49.0909091 L60,72.3 L92.2909091,72.3 C90.9,79.8 86.6727273,86.1545455 80.3181818,90.4090909 L80.3181818,105.463636 L99.7090909,105.463636 C111.054545,95.0181818 117.6,79.6363636 117.6,61.3636364 L117.6,61.3636364 Z"
|
||||
id="Shape"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M60,120 C76.2,120 89.7818182,114.627273 99.7090909,105.463636 L80.3181818,90.4090909 C74.9454545,94.0090909 68.0727273,96.1363636 60,96.1363636 C44.3727273,96.1363636 31.1454545,85.5818182 26.4272727,71.4 L6.38181818,71.4 L6.38181818,86.9454545 C16.2545455,106.554545 36.5454545,120 60,120 L60,120 Z"
|
||||
id="Shape"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M26.4272727,71.4 C25.2272727,67.8 24.5454545,63.9545455 24.5454545,60 C24.5454545,56.0454545 25.2272727,52.2 26.4272727,48.6 L26.4272727,33.0545455 L6.38181818,33.0545455 C2.31818182,41.1545455 0,50.3181818 0,60 C0,69.6818182 2.31818182,78.8454545 6.38181818,86.9454545 L26.4272727,71.4 L26.4272727,71.4 Z"
|
||||
id="Shape"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M60,23.8636364 C68.8090909,23.8636364 76.7181818,26.8909091 82.9363636,32.8363636 L100.145455,15.6272727 C89.7545455,5.94545455 76.1727273,0 60,0 C36.5454545,0 16.2545455,13.4454545 6.38181818,33.0545455 L26.4272727,48.6 C31.1454545,34.4181818 44.3727273,23.8636364 60,23.8636364 L60,23.8636364 Z"
|
||||
id="Shape"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M0,0 L120,0 L120,120 L0,120 L0,0 Z"
|
||||
id="Shape"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Firebase = () => {
|
||||
const [auth, provider] = useMemo(() => {
|
||||
const auth = getAuth(_app);
|
||||
auth.setPersistence(browserLocalPersistence);
|
||||
const provider = new GoogleAuthProvider();
|
||||
return [auth, provider];
|
||||
}, []);
|
||||
|
||||
const handleAuth = useCallback(() => {
|
||||
signInWithPopup(auth, provider).catch(error => {
|
||||
const errorCode = error.code;
|
||||
const errorMessage = error.message;
|
||||
const email = error.customData.email;
|
||||
const credential = GoogleAuthProvider.credentialFromError(error);
|
||||
console.log(errorCode, errorMessage, email, credential);
|
||||
});
|
||||
}, [auth, provider]);
|
||||
|
||||
return (
|
||||
<MuiGrid container>
|
||||
<MuiGrid item xs={8}>
|
||||
<Error
|
||||
title="Welcome to Affine"
|
||||
subTitle="blocks of knowledge to power your team"
|
||||
action1Text="Login or Register"
|
||||
/>
|
||||
</MuiGrid>
|
||||
|
||||
<MuiGrid item xs={4}>
|
||||
<MuiBox
|
||||
onSubmit={handleAuth}
|
||||
onClick={handleAuth}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
width: '300px',
|
||||
margin: '300px auto 20px auto',
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
<LogoImg
|
||||
style={{
|
||||
width: '100px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
style={{ textTransform: 'none' }}
|
||||
startIcon={<GoogleIcon />}
|
||||
>
|
||||
Continue with Google
|
||||
</MuiButton>
|
||||
</MuiBox>
|
||||
</MuiGrid>
|
||||
</MuiGrid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// import { Authing } from './authing';
|
||||
import { Firebase } from './firebase';
|
||||
|
||||
export function Login() {
|
||||
return (
|
||||
<>
|
||||
{/* <Authing /> */}
|
||||
<Firebase />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# components-affine-board
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test components-affine-board` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'components-affine-board',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/affine-board',
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@toeverything/components/affine-board",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/components/affine-board/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/affine-board",
|
||||
"tsConfig": "libs/components/affine-board/tsconfig.lib.json",
|
||||
"project": "libs/components/affine-board/package.json",
|
||||
"entryFile": "libs/components/affine-board/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/affine-board/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/affine-board/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/affine-board"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/affine-board/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tldraw } from '@toeverything/components/board-draw';
|
||||
import { tools } from '@toeverything/components/board-tools';
|
||||
import { getSession } from '@toeverything/components/board-sessions';
|
||||
import * as commands from '@toeverything/components/board-commands';
|
||||
import { TldrawApp, deepCopy } from '@toeverything/components/board-state';
|
||||
import { TDShapeType } from '@toeverything/components/board-types';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import { useShapes } from './hooks';
|
||||
import { RecastBlockProvider } from '@toeverything/components/editor-core';
|
||||
import { createEditor } from '@toeverything/components/affine-editor';
|
||||
import { AsyncBlock, BlockEditor } from '@toeverything/framework/virgo';
|
||||
|
||||
interface AffineBoardProps {
|
||||
workspace: string;
|
||||
rootBlockId: string;
|
||||
}
|
||||
|
||||
const AffineBoard = ({ workspace, rootBlockId }: AffineBoardProps) => {
|
||||
const [app, set_app] = useState<TldrawApp>();
|
||||
|
||||
const [document] = useState(() => {
|
||||
return {
|
||||
...deepCopy(TldrawApp.default_document),
|
||||
id: workspace,
|
||||
pages: {
|
||||
[rootBlockId]: {
|
||||
id: rootBlockId,
|
||||
name: `Page ${rootBlockId}`,
|
||||
childIndex: 1,
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[rootBlockId]: {
|
||||
id: rootBlockId,
|
||||
camera: {
|
||||
point: [0, 0],
|
||||
zoom: 1,
|
||||
},
|
||||
selectedIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const shapes = useShapes(workspace, rootBlockId);
|
||||
useEffect(() => {
|
||||
if (app) {
|
||||
app.replacePageContent(shapes || {}, {}, {});
|
||||
}
|
||||
}, [app, shapes]);
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
document={document}
|
||||
commands={commands}
|
||||
tools={tools}
|
||||
getSession={getSession}
|
||||
callbacks={{
|
||||
onMount(app) {
|
||||
set_app(app);
|
||||
},
|
||||
onChangePage(app, shapes, bindings, assets) {
|
||||
Promise.all(
|
||||
Object.entries(shapes).map(async ([id, shape]) => {
|
||||
if (shape === undefined) {
|
||||
return services.api.editorBlock.delete({
|
||||
workspace: workspace,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
let block = (
|
||||
await services.api.editorBlock.get({
|
||||
workspace: workspace,
|
||||
ids: [shape.affineId],
|
||||
})
|
||||
)?.[0];
|
||||
if (!block) {
|
||||
block =
|
||||
await services.api.editorBlock.create({
|
||||
workspace: workspace,
|
||||
parentId:
|
||||
app.appState.currentPageId,
|
||||
type:
|
||||
shape.type ===
|
||||
TDShapeType.Editor
|
||||
? 'group'
|
||||
: 'shape',
|
||||
});
|
||||
}
|
||||
shape.affineId = block.id;
|
||||
return services.api.editorBlock.update({
|
||||
workspace: shape.workspace,
|
||||
id: block.id,
|
||||
properties: {
|
||||
shapeProps: {
|
||||
value: JSON.stringify(shape),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AffineBoardWitchContext = ({
|
||||
workspace,
|
||||
rootBlockId,
|
||||
}: AffineBoardProps) => {
|
||||
const [editor, set_editor] = useState<BlockEditor>();
|
||||
useEffect(() => {
|
||||
const inner_editor = createEditor(workspace, true);
|
||||
set_editor(inner_editor);
|
||||
return () => {
|
||||
inner_editor.dispose();
|
||||
};
|
||||
}, [workspace]);
|
||||
|
||||
const [page, set_page] = useState<AsyncBlock>();
|
||||
useEffect(() => {
|
||||
editor?.getBlockById(rootBlockId).then(block => {
|
||||
set_page(block);
|
||||
});
|
||||
}, [editor, rootBlockId]);
|
||||
return page ? (
|
||||
<RecastBlockProvider block={page}>
|
||||
<AffineBoard workspace={workspace} rootBlockId={rootBlockId} />
|
||||
</RecastBlockProvider>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './use-shapes';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { services } from '@toeverything/datasource/db-service';
|
||||
import type { ReturnEditorBlock } from '@toeverything/datasource/db-service';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { Editor } from '@toeverything/components/board-shapes';
|
||||
|
||||
export const useShapes = (workspace: string, rootBlockId: string) => {
|
||||
const [blocks, setBlocks] = useState<ReturnEditorBlock[]>();
|
||||
useEffect(() => {
|
||||
services.api.editorBlock
|
||||
.get({ workspace, ids: [rootBlockId] })
|
||||
.then(async blockData => {
|
||||
const shapes = await Promise.all(
|
||||
(blockData?.[0]?.children || []).map(async childId => {
|
||||
const childBlock = (
|
||||
await services.api.editorBlock.get({
|
||||
workspace,
|
||||
ids: [childId],
|
||||
})
|
||||
)?.[0];
|
||||
return childBlock;
|
||||
})
|
||||
);
|
||||
setBlocks(shapes);
|
||||
});
|
||||
let unobserve: () => void;
|
||||
services.api.editorBlock
|
||||
.observe({ workspace, id: rootBlockId }, async blockData => {
|
||||
const shapes = await Promise.all(
|
||||
(blockData?.children || []).map(async childId => {
|
||||
const childBlock = (
|
||||
await services.api.editorBlock.get({
|
||||
workspace,
|
||||
ids: [childId],
|
||||
})
|
||||
)?.[0];
|
||||
return childBlock;
|
||||
})
|
||||
);
|
||||
setBlocks(shapes);
|
||||
})
|
||||
.then(cb => {
|
||||
unobserve = cb;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unobserve?.();
|
||||
};
|
||||
}, [workspace, rootBlockId]);
|
||||
|
||||
let groupCount = 0;
|
||||
|
||||
return blocks?.reduce((acc, block) => {
|
||||
const shapeProps = block.properties.shapeProps?.value
|
||||
? JSON.parse(block.properties.shapeProps.value)
|
||||
: {};
|
||||
if (block.type === 'shape') {
|
||||
acc[block.id] = { ...shapeProps, id: block.id };
|
||||
} else {
|
||||
acc[block.id] = Editor.getShape({
|
||||
point: [groupCount * 740, 200],
|
||||
id: block.id,
|
||||
...shapeProps,
|
||||
affineId: shapeProps.affineId ?? block.id,
|
||||
workspace: block.workspace,
|
||||
rootBlockId: block.id,
|
||||
});
|
||||
groupCount = groupCount + 1;
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, TDShape>);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AffineBoardWitchContext as AffineBoard } from './Board';
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
displayName: 'components-affine-editor',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/affine-editor',
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@toeverything/components/affine-editor",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"sourceRoot": "libs/components/affine-editor/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components:affine-editor"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/affine-editor",
|
||||
"tsConfig": "libs/components/affine-editor/tsconfig.lib.json",
|
||||
"project": "libs/components/affine-editor/package.json",
|
||||
"entryFile": "libs/components/affine-editor/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/affine-editor/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/affine-editor/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/affine-editor"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/affine-editor/jest.config.js",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { RenderRoot, RenderBlock } from '@toeverything/components/editor-core';
|
||||
import { useCurrentEditors } from '@toeverything/datasource/state';
|
||||
|
||||
import { createEditor } from './create-editor';
|
||||
|
||||
interface AffineEditorProps {
|
||||
workspace: string;
|
||||
rootBlockId: string;
|
||||
/**
|
||||
* Whether to show the visual blank at the bottom of the article
|
||||
*/
|
||||
scrollBlank?: boolean;
|
||||
|
||||
isWhiteboard?: boolean;
|
||||
}
|
||||
|
||||
function useConstant<T>(init: () => T): T {
|
||||
const ref = useRef<T>(null);
|
||||
ref.current ??= init();
|
||||
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export const AffineEditor = ({
|
||||
workspace,
|
||||
rootBlockId,
|
||||
scrollBlank = true,
|
||||
isWhiteboard,
|
||||
}: AffineEditorProps) => {
|
||||
const { setCurrentEditors } = useCurrentEditors();
|
||||
const editor = useConstant(() => {
|
||||
const editor = createEditor(workspace, isWhiteboard);
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.virgo = editor;
|
||||
return editor;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (rootBlockId) {
|
||||
editor.setRootBlockId(rootBlockId);
|
||||
} else {
|
||||
console.error('rootBlockId for page is required. ');
|
||||
}
|
||||
}, [editor, rootBlockId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rootBlockId) return;
|
||||
setCurrentEditors(prev => ({ ...prev, [rootBlockId]: editor }));
|
||||
}, [editor, rootBlockId, setCurrentEditors]);
|
||||
|
||||
return (
|
||||
<RenderRoot
|
||||
editor={editor}
|
||||
editorElement={AffineEditor as any}
|
||||
scrollBlank={scrollBlank}
|
||||
>
|
||||
<RenderBlock blockId={rootBlockId} />
|
||||
</RenderRoot>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BlockEditor } from '@toeverything/framework/virgo';
|
||||
import {
|
||||
PageBlock,
|
||||
RefLinkBlock,
|
||||
TextBlock,
|
||||
Heading1Block,
|
||||
Heading2Block,
|
||||
Heading3Block,
|
||||
QuoteBlock,
|
||||
TodoBlock,
|
||||
CodeBlock,
|
||||
// TocBlock,
|
||||
FileBlock,
|
||||
ImageBlock,
|
||||
DividerBlock,
|
||||
CalloutBlock,
|
||||
YoutubeBlock,
|
||||
FigmaBlock,
|
||||
GroupBlock,
|
||||
EmbedLinkBlock,
|
||||
BulletBlock,
|
||||
NumberedBlock,
|
||||
GridBlock,
|
||||
GridItemBlock,
|
||||
GroupDividerBlock,
|
||||
} from '@toeverything/components/editor-blocks';
|
||||
import { Protocol } from '@toeverything/datasource/db-service';
|
||||
import { plugins } from '@toeverything/components/editor-plugins';
|
||||
|
||||
export const createEditor = (workspace: string, isWhiteboard?: boolean) => {
|
||||
const blockEditor = new BlockEditor({
|
||||
workspace,
|
||||
views: {
|
||||
[Protocol.Block.Type.page]: new PageBlock(),
|
||||
[Protocol.Block.Type.reference]: new RefLinkBlock(),
|
||||
[Protocol.Block.Type.text]: new TextBlock(),
|
||||
[Protocol.Block.Type.heading1]: new Heading1Block(),
|
||||
[Protocol.Block.Type.heading2]: new Heading2Block(),
|
||||
[Protocol.Block.Type.heading3]: new Heading3Block(),
|
||||
[Protocol.Block.Type.quote]: new QuoteBlock(),
|
||||
[Protocol.Block.Type.todo]: new TodoBlock(),
|
||||
[Protocol.Block.Type.code]: new CodeBlock(),
|
||||
// [Protocol.Block.Type.toc]: new TocBlock(),
|
||||
[Protocol.Block.Type.file]: new FileBlock(),
|
||||
[Protocol.Block.Type.image]: new ImageBlock(),
|
||||
[Protocol.Block.Type.divider]: new DividerBlock(),
|
||||
[Protocol.Block.Type.callout]: new CalloutBlock(),
|
||||
[Protocol.Block.Type.youtube]: new YoutubeBlock(),
|
||||
[Protocol.Block.Type.figma]: new FigmaBlock(),
|
||||
[Protocol.Block.Type.group]: new GroupBlock(),
|
||||
[Protocol.Block.Type.embedLink]: new EmbedLinkBlock(),
|
||||
[Protocol.Block.Type.numbered]: new NumberedBlock(),
|
||||
[Protocol.Block.Type.bullet]: new BulletBlock(),
|
||||
[Protocol.Block.Type.grid]: new GridBlock(),
|
||||
[Protocol.Block.Type.gridItem]: new GridItemBlock(),
|
||||
[Protocol.Block.Type.groupDivider]: new GroupDividerBlock(),
|
||||
},
|
||||
plugins,
|
||||
isWhiteboard,
|
||||
});
|
||||
|
||||
return blockEditor;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AffineEditor } from './Editor';
|
||||
export { createEditor } from './create-editor';
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# components-board-commands
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test components-board-commands` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'components-board-commands',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/board-commands',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@toeverything/components/board-commands",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tldraw/core": "^1.12.0",
|
||||
"@tldraw/intersect": "^1.7.1",
|
||||
"@tldraw/vec": "^1.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/components/board-commands/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/board-commands",
|
||||
"tsConfig": "libs/components/board-commands/tsconfig.lib.json",
|
||||
"project": "libs/components/board-commands/package.json",
|
||||
"entryFile": "libs/components/board-commands/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/board-commands/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/board-commands/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/board-commands"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/board-commands/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import {
|
||||
AlignType,
|
||||
TldrawCommand,
|
||||
TDShapeType,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function alignShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: AlignType
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const initialShapes = ids.map(id => app.getShape(id));
|
||||
|
||||
const boundsForShapes = initialShapes.map(shape => {
|
||||
return {
|
||||
id: shape.id,
|
||||
point: [...shape.point],
|
||||
bounds: TLDR.get_bounds(shape),
|
||||
};
|
||||
});
|
||||
|
||||
const commonBounds = Utils.getCommonBounds(
|
||||
boundsForShapes.map(({ bounds }) => bounds)
|
||||
);
|
||||
|
||||
const midX = commonBounds.minX + commonBounds.width / 2;
|
||||
const midY = commonBounds.minY + commonBounds.height / 2;
|
||||
|
||||
const deltaMap = Object.fromEntries(
|
||||
boundsForShapes.map(({ id, point, bounds }) => {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
prev: point,
|
||||
next: {
|
||||
[AlignType.CenterVertical]: [
|
||||
point[0],
|
||||
midY - bounds.height / 2,
|
||||
],
|
||||
[AlignType.CenterHorizontal]: [
|
||||
midX - bounds.width / 2,
|
||||
point[1],
|
||||
],
|
||||
[AlignType.Top]: [point[0], commonBounds.minY],
|
||||
[AlignType.Bottom]: [
|
||||
point[0],
|
||||
commonBounds.maxY - bounds.height,
|
||||
],
|
||||
[AlignType.Left]: [commonBounds.minX, point[1]],
|
||||
[AlignType.Right]: [
|
||||
commonBounds.maxX - bounds.width,
|
||||
point[1],
|
||||
],
|
||||
}[type],
|
||||
},
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
const { before, after } = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
ids,
|
||||
shape => {
|
||||
if (!deltaMap[shape.id]) return shape;
|
||||
return { point: deltaMap[shape.id].next };
|
||||
},
|
||||
currentPageId
|
||||
);
|
||||
|
||||
initialShapes.forEach(shape => {
|
||||
if (shape.type === TDShapeType.Group) {
|
||||
const delta = Vec.sub(
|
||||
after[shape.id].point!,
|
||||
before[shape.id].point!
|
||||
);
|
||||
|
||||
shape.children.forEach(id => {
|
||||
const child = app.getShape(id);
|
||||
before[child.id] = { point: child.point };
|
||||
after[child.id] = { point: Vec.add(child.point, delta) };
|
||||
});
|
||||
|
||||
delete before[shape.id];
|
||||
delete after[shape.id];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'align',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: before,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: after,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function changePage(app: TldrawApp, pageId: string): TldrawCommand {
|
||||
return {
|
||||
id: 'change_page',
|
||||
before: {
|
||||
appState: {
|
||||
currentPageId: app.currentPageId,
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: pageId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
TldrawCommand,
|
||||
TDPage,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { Utils, TLPageState } from '@tldraw/core';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function createPage(
|
||||
app: TldrawApp,
|
||||
center: number[],
|
||||
pageId = Utils.uniqueId()
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const topPage = Object.values(app.state.document.pages).sort(
|
||||
(a, b) => (b.childIndex || 0) - (a.childIndex || 0)
|
||||
)[0];
|
||||
|
||||
const nextChildIndex = topPage?.childIndex ? topPage?.childIndex + 1 : 1;
|
||||
|
||||
// TODO: Iterate the name better
|
||||
const nextName = `New Page`;
|
||||
|
||||
const page: TDPage = {
|
||||
id: pageId,
|
||||
name: nextName,
|
||||
childIndex: nextChildIndex,
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const pageState: TLPageState = {
|
||||
id: pageId,
|
||||
selectedIds: [],
|
||||
camera: { point: center, zoom: 1 },
|
||||
editingId: undefined,
|
||||
bindingId: undefined,
|
||||
hoveredId: undefined,
|
||||
pointedId: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'create_page',
|
||||
before: {
|
||||
appState: {
|
||||
currentPageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: undefined,
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: page.id,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: page,
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: pageState,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
Patch,
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
TDBinding,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function createShapes(
|
||||
app: TldrawApp,
|
||||
shapes: TDShape[],
|
||||
bindings: TDBinding[] = []
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const beforeShapes: Record<string, Patch<TDShape> | undefined> = {};
|
||||
const afterShapes: Record<string, Patch<TDShape> | undefined> = {};
|
||||
|
||||
shapes.forEach(shape => {
|
||||
beforeShapes[shape.id] = undefined;
|
||||
afterShapes[shape.id] = shape;
|
||||
});
|
||||
|
||||
const beforeBindings: Record<string, Patch<TDBinding> | undefined> = {};
|
||||
const afterBindings: Record<string, Patch<TDBinding> | undefined> = {};
|
||||
|
||||
bindings.forEach(binding => {
|
||||
beforeBindings[binding.id] = undefined;
|
||||
afterBindings[binding.id] = binding;
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'create',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: beforeShapes,
|
||||
bindings: beforeBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: [...app.selectedIds],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: afterShapes,
|
||||
bindings: afterBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: shapes.map(shape => shape.id),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function deletePage(app: TldrawApp, pageId: string): TldrawCommand {
|
||||
const {
|
||||
currentPageId,
|
||||
document: { pages, pageStates },
|
||||
} = app;
|
||||
|
||||
const pagesArr = Object.values(pages).sort(
|
||||
(a, b) => (a.childIndex || 0) - (b.childIndex || 0)
|
||||
);
|
||||
|
||||
const currentIndex = pagesArr.findIndex(page => page.id === pageId);
|
||||
|
||||
let nextCurrentPageId: string;
|
||||
|
||||
if (pageId === currentPageId) {
|
||||
if (currentIndex === pagesArr.length - 1) {
|
||||
nextCurrentPageId = pagesArr[pagesArr.length - 2].id;
|
||||
} else {
|
||||
nextCurrentPageId = pagesArr[currentIndex + 1].id;
|
||||
}
|
||||
} else {
|
||||
nextCurrentPageId = currentPageId;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'delete_page',
|
||||
before: {
|
||||
appState: {
|
||||
currentPageId: pageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: { ...pages[pageId] },
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: { ...pageStates[pageId] },
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: nextCurrentPageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: undefined,
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type {
|
||||
TDAsset,
|
||||
TDAssets,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { removeShapesFromPage } from './shared/remove-shapes-from-page';
|
||||
|
||||
const removeAssetsFromDocument = (assets: TDAssets, idsToRemove: string[]) => {
|
||||
const afterAssets: Record<string, TDAsset | undefined> = { ...assets };
|
||||
idsToRemove.forEach(id => (afterAssets[id] = undefined));
|
||||
return afterAssets;
|
||||
};
|
||||
|
||||
export function deleteShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
pageId = app.currentPageId
|
||||
): TldrawCommand {
|
||||
const {
|
||||
pageState,
|
||||
selectedIds,
|
||||
document: { assets: beforeAssets },
|
||||
} = app;
|
||||
const { before, after, assetsToRemove } = removeShapesFromPage(
|
||||
app.state,
|
||||
ids,
|
||||
pageId
|
||||
);
|
||||
const afterAssets = removeAssetsFromDocument(beforeAssets, assetsToRemove);
|
||||
|
||||
return {
|
||||
id: 'delete',
|
||||
before: {
|
||||
document: {
|
||||
assets: beforeAssets,
|
||||
pages: {
|
||||
[pageId]: before,
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: { selectedIds: [...app.selectedIds] },
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
assets: afterAssets,
|
||||
pages: {
|
||||
[pageId]: after,
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: {
|
||||
selectedIds: selectedIds.filter(
|
||||
id => !ids.includes(id)
|
||||
),
|
||||
hoveredId:
|
||||
pageState.hoveredId &&
|
||||
ids.includes(pageState.hoveredId)
|
||||
? undefined
|
||||
: pageState.hoveredId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Utils } from '@tldraw/core';
|
||||
import {
|
||||
DistributeType,
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
TDShapeType,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import Vec from '@tldraw/vec';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function distributeShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: DistributeType
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const initialShapes = ids.map(id => app.getShape(id));
|
||||
|
||||
const deltaMap = Object.fromEntries(
|
||||
getDistributions(initialShapes, type).map(d => [d.id, d])
|
||||
);
|
||||
|
||||
const { before, after } = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
ids.filter(id => deltaMap[id] !== undefined),
|
||||
shape => ({ point: deltaMap[shape.id].next }),
|
||||
currentPageId
|
||||
);
|
||||
|
||||
initialShapes.forEach(shape => {
|
||||
if (shape.type === TDShapeType.Group) {
|
||||
const delta = Vec.sub(
|
||||
after[shape.id].point!,
|
||||
before[shape.id].point!
|
||||
);
|
||||
|
||||
shape.children.forEach(id => {
|
||||
const child = app.getShape(id);
|
||||
before[child.id] = { point: child.point };
|
||||
after[child.id] = { point: Vec.add(child.point, delta) };
|
||||
});
|
||||
|
||||
delete before[shape.id];
|
||||
delete after[shape.id];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'distribute',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getDistributions(initialShapes: TDShape[], type: DistributeType) {
|
||||
const entries = initialShapes.map(shape => {
|
||||
const utils = TLDR.get_shape_util(shape);
|
||||
return {
|
||||
id: shape.id,
|
||||
point: [...shape.point],
|
||||
bounds: utils.getBounds(shape),
|
||||
center: utils.getCenter(shape),
|
||||
};
|
||||
});
|
||||
|
||||
const len = entries.length;
|
||||
const commonBounds = Utils.getCommonBounds(
|
||||
entries.map(({ bounds }) => bounds)
|
||||
);
|
||||
|
||||
const results: { id: string; prev: number[]; next: number[] }[] = [];
|
||||
|
||||
switch (type) {
|
||||
case DistributeType.Horizontal: {
|
||||
const span = entries.reduce((a, c) => a + c.bounds.width, 0);
|
||||
|
||||
if (span > commonBounds.width) {
|
||||
const left = entries.sort(
|
||||
(a, b) => a.bounds.minX - b.bounds.minX
|
||||
)[0];
|
||||
|
||||
const right = entries.sort(
|
||||
(a, b) => b.bounds.maxX - a.bounds.maxX
|
||||
)[0];
|
||||
|
||||
const entriesToMove = entries
|
||||
.filter(a => a !== left && a !== right)
|
||||
.sort((a, b) => a.center[0] - b.center[0]);
|
||||
|
||||
const step = (right.center[0] - left.center[0]) / (len - 1);
|
||||
|
||||
const x = left.center[0] + step;
|
||||
|
||||
entriesToMove.forEach(({ id, point, bounds }, i) => {
|
||||
results.push({
|
||||
id,
|
||||
prev: point,
|
||||
next: [x + step * i - bounds.width / 2, bounds.minY],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const entriesToMove = entries.sort(
|
||||
(a, b) => a.center[0] - b.center[0]
|
||||
);
|
||||
|
||||
let x = commonBounds.minX;
|
||||
const step = (commonBounds.width - span) / (len - 1);
|
||||
|
||||
entriesToMove.forEach(({ id, point, bounds }) => {
|
||||
results.push({ id, prev: point, next: [x, bounds.minY] });
|
||||
x += bounds.width + step;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DistributeType.Vertical: {
|
||||
const span = entries.reduce((a, c) => a + c.bounds.height, 0);
|
||||
|
||||
if (span > commonBounds.height) {
|
||||
const top = entries.sort(
|
||||
(a, b) => a.bounds.minY - b.bounds.minY
|
||||
)[0];
|
||||
|
||||
const bottom = entries.sort(
|
||||
(a, b) => b.bounds.maxY - a.bounds.maxY
|
||||
)[0];
|
||||
|
||||
const entriesToMove = entries
|
||||
.filter(a => a !== top && a !== bottom)
|
||||
.sort((a, b) => a.center[1] - b.center[1]);
|
||||
|
||||
const step = (bottom.center[1] - top.center[1]) / (len - 1);
|
||||
|
||||
const y = top.center[1] + step;
|
||||
|
||||
entriesToMove.forEach(({ id, point, bounds }, i) => {
|
||||
results.push({
|
||||
id,
|
||||
prev: point,
|
||||
next: [bounds.minX, y + step * i - bounds.height / 2],
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const entriesToMove = entries.sort(
|
||||
(a, b) => a.center[1] - b.center[1]
|
||||
);
|
||||
|
||||
let y = commonBounds.minY;
|
||||
const step = (commonBounds.height - span) / (len - 1);
|
||||
|
||||
entriesToMove.forEach(({ id, point, bounds }) => {
|
||||
results.push({ id, prev: point, next: [bounds.minX, y] });
|
||||
y += bounds.height + step;
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function duplicatePage(app: TldrawApp, pageId: string): TldrawCommand {
|
||||
const newId = Utils.uniqueId();
|
||||
const {
|
||||
currentPageId,
|
||||
page,
|
||||
pageState: { camera },
|
||||
} = app;
|
||||
|
||||
const nextPage = {
|
||||
...page,
|
||||
id: newId,
|
||||
name: page.name + ' Copy',
|
||||
shapes: Object.fromEntries(
|
||||
Object.entries(page.shapes).map(([id, shape]) => {
|
||||
return [
|
||||
id,
|
||||
{
|
||||
...shape,
|
||||
parentId:
|
||||
shape.parentId === pageId ? newId : shape.parentId,
|
||||
},
|
||||
];
|
||||
})
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'duplicate_page',
|
||||
before: {
|
||||
appState: {
|
||||
currentPageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[newId]: undefined,
|
||||
},
|
||||
pageStates: {
|
||||
[newId]: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: newId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[newId]: nextPage,
|
||||
},
|
||||
pageStates: {
|
||||
[newId]: {
|
||||
...page,
|
||||
id: newId,
|
||||
selectedIds: [],
|
||||
camera: { ...camera },
|
||||
editingId: undefined,
|
||||
bindingId: undefined,
|
||||
hoveredId: undefined,
|
||||
pointedId: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Utils } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type {
|
||||
PagePartial,
|
||||
TldrawCommand,
|
||||
TDShape,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function duplicateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
point?: number[]
|
||||
): TldrawCommand {
|
||||
const { selectedIds, currentPageId, page, shapes } = app;
|
||||
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const after: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const duplicateMap: Record<string, string> = {};
|
||||
|
||||
const shapesToDuplicate = ids
|
||||
.map(id => app.getShape(id))
|
||||
.filter(shape => !ids.includes(shape.parentId));
|
||||
|
||||
// Create duplicates
|
||||
shapesToDuplicate.forEach(shape => {
|
||||
const duplicatedId = Utils.uniqueId();
|
||||
before.shapes[duplicatedId] = undefined;
|
||||
|
||||
after.shapes[duplicatedId] = {
|
||||
...Utils.deepClone(shape),
|
||||
id: duplicatedId,
|
||||
childIndex: TLDR.get_child_index_above(
|
||||
app.state,
|
||||
shape.id,
|
||||
currentPageId
|
||||
),
|
||||
};
|
||||
|
||||
if (shape.children) {
|
||||
after.shapes[duplicatedId]!.children = [];
|
||||
}
|
||||
|
||||
if (shape.parentId !== currentPageId) {
|
||||
const parent = app.getShape(shape.parentId);
|
||||
|
||||
before.shapes[parent.id] = {
|
||||
...before.shapes[parent.id],
|
||||
children: parent.children,
|
||||
};
|
||||
|
||||
after.shapes[parent.id] = {
|
||||
...after.shapes[parent.id],
|
||||
children: [
|
||||
...(after.shapes[parent.id] || parent).children!,
|
||||
duplicatedId,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
duplicateMap[shape.id] = duplicatedId;
|
||||
});
|
||||
|
||||
// If the shapes have children, then duplicate those too
|
||||
shapesToDuplicate.forEach(shape => {
|
||||
if (shape.children) {
|
||||
shape.children.forEach(childId => {
|
||||
const child = app.getShape(childId);
|
||||
const duplicatedId = Utils.uniqueId();
|
||||
const duplicatedParentId = duplicateMap[shape.id];
|
||||
before.shapes[duplicatedId] = undefined;
|
||||
after.shapes[duplicatedId] = {
|
||||
...Utils.deepClone(child),
|
||||
id: duplicatedId,
|
||||
parentId: duplicatedParentId,
|
||||
childIndex: TLDR.get_child_index_above(
|
||||
app.state,
|
||||
child.id,
|
||||
currentPageId
|
||||
),
|
||||
};
|
||||
duplicateMap[childId] = duplicatedId;
|
||||
after.shapes[duplicateMap[shape.id]]?.children?.push(
|
||||
duplicatedId
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Which ids did we end up duplicating?
|
||||
const dupedShapeIds = new Set(Object.keys(duplicateMap));
|
||||
|
||||
// Handle bindings that effect duplicated shapes
|
||||
Object.values(page.bindings)
|
||||
.filter(
|
||||
binding =>
|
||||
dupedShapeIds.has(binding.fromId) ||
|
||||
dupedShapeIds.has(binding.toId)
|
||||
)
|
||||
.forEach(binding => {
|
||||
if (dupedShapeIds.has(binding.fromId)) {
|
||||
if (dupedShapeIds.has(binding.toId)) {
|
||||
// If the binding is between two duplicating shapes then
|
||||
// duplicate the binding, too
|
||||
const duplicatedBindingId = Utils.uniqueId();
|
||||
|
||||
const duplicatedBinding = {
|
||||
...Utils.deepClone(binding),
|
||||
id: duplicatedBindingId,
|
||||
fromId: duplicateMap[binding.fromId],
|
||||
toId: duplicateMap[binding.toId],
|
||||
};
|
||||
|
||||
before.bindings[duplicatedBindingId] = undefined;
|
||||
after.bindings[duplicatedBindingId] = duplicatedBinding;
|
||||
|
||||
// Change the duplicated shape's handle so that it reference
|
||||
// the duplicated binding
|
||||
const boundShape = after.shapes[duplicatedBinding.fromId];
|
||||
Object.values(boundShape!.handles!).forEach(handle => {
|
||||
if (handle!.bindingId === binding.id) {
|
||||
handle!.bindingId = duplicatedBindingId;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If only the fromId is selected, delete the binding on
|
||||
// the duplicated shape's handles
|
||||
const boundShape =
|
||||
after.shapes[duplicateMap[binding.fromId]];
|
||||
Object.values(boundShape!.handles!).forEach(handle => {
|
||||
if (handle!.bindingId === binding.id) {
|
||||
handle!.bindingId = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Now move the shapes
|
||||
|
||||
const shapesToMove = Object.values(after.shapes) as TDShape[];
|
||||
|
||||
if (point) {
|
||||
const commonBounds = Utils.getCommonBounds(
|
||||
shapesToMove.map(shape => TLDR.get_bounds(shape))
|
||||
);
|
||||
const center = Utils.getBoundsCenter(commonBounds);
|
||||
shapesToMove.forEach(shape => {
|
||||
// Could be a group
|
||||
if (!shape.point) return;
|
||||
shape.point = Vec.sub(point, Vec.sub(center, shape.point));
|
||||
});
|
||||
} else {
|
||||
const offset = [16, 16];
|
||||
shapesToMove.forEach(shape => {
|
||||
// Could be a group
|
||||
if (!shape.point) return;
|
||||
shape.point = Vec.add(shape.point, offset);
|
||||
});
|
||||
}
|
||||
|
||||
// Unlock any locked shapes
|
||||
shapesToMove.forEach(shape => {
|
||||
if (shape.isLocked) {
|
||||
shape.isLocked = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'duplicate',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: before,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: { selectedIds },
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: after,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: Array.from(dupedShapeIds.values()).map(
|
||||
id => duplicateMap[id]
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FlipType } from '@toeverything/components/board-types';
|
||||
import { TLBoundsCorner, Utils } from '@tldraw/core';
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
|
||||
export function flipShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: FlipType
|
||||
): TldrawCommand {
|
||||
const { selectedIds, currentPageId, shapes } = app;
|
||||
|
||||
const boundsForShapes = shapes.map(shape => TLDR.get_bounds(shape));
|
||||
|
||||
const commonBounds = Utils.getCommonBounds(boundsForShapes);
|
||||
|
||||
const { before, after } = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
ids,
|
||||
shape => {
|
||||
const shapeBounds = TLDR.get_bounds(shape);
|
||||
|
||||
switch (type) {
|
||||
case FlipType.Horizontal: {
|
||||
const newShapeBounds =
|
||||
Utils.getRelativeTransformedBoundingBox(
|
||||
commonBounds,
|
||||
commonBounds,
|
||||
shapeBounds,
|
||||
true,
|
||||
false
|
||||
);
|
||||
|
||||
return TLDR.get_shape_util(shape).transform(
|
||||
shape,
|
||||
newShapeBounds,
|
||||
{
|
||||
type: TLBoundsCorner.TopLeft,
|
||||
scaleX: -1,
|
||||
scaleY: 1,
|
||||
initialShape: shape,
|
||||
transformOrigin: [0.5, 0.5],
|
||||
}
|
||||
);
|
||||
}
|
||||
case FlipType.Vertical: {
|
||||
const newShapeBounds =
|
||||
Utils.getRelativeTransformedBoundingBox(
|
||||
commonBounds,
|
||||
commonBounds,
|
||||
shapeBounds,
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
return TLDR.get_shape_util(shape).transform(
|
||||
shape,
|
||||
newShapeBounds,
|
||||
{
|
||||
type: TLBoundsCorner.TopLeft,
|
||||
scaleX: 1,
|
||||
scaleY: -1,
|
||||
initialShape: shape,
|
||||
transformOrigin: [0.5, 0.5],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
currentPageId
|
||||
);
|
||||
|
||||
return {
|
||||
id: 'flip',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { TDShape, TDShapeType } from '@toeverything/components/board-types';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type {
|
||||
Patch,
|
||||
TldrawCommand,
|
||||
TDBinding,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
|
||||
export function groupShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
groupId: string,
|
||||
pageId: string
|
||||
): TldrawCommand | undefined {
|
||||
if (ids.length < 2) return;
|
||||
|
||||
const beforeShapes: Record<string, Patch<TDShape | undefined>> = {};
|
||||
const afterShapes: Record<string, Patch<TDShape | undefined>> = {};
|
||||
|
||||
const beforeBindings: Record<string, Patch<TDBinding | undefined>> = {};
|
||||
const afterBindings: Record<string, Patch<TDBinding | undefined>> = {};
|
||||
|
||||
const idsToGroup = [...ids];
|
||||
const shapesToGroup: TDShape[] = [];
|
||||
const deletedGroupIds: string[] = [];
|
||||
const otherEffectedGroups: TDShape[] = [];
|
||||
|
||||
// Collect all of the shapes to group (and their ids)
|
||||
for (const id of ids) {
|
||||
const shape = app.getShape(id);
|
||||
if (shape.isLocked) continue;
|
||||
|
||||
if (shape.children === undefined) {
|
||||
shapesToGroup.push(shape);
|
||||
} else {
|
||||
const childIds = shape.children.filter(
|
||||
id => !app.getShape(id).isLocked
|
||||
);
|
||||
otherEffectedGroups.push(shape);
|
||||
idsToGroup.push(...childIds);
|
||||
shapesToGroup.push(
|
||||
...childIds.map(id => app.getShape(id)).filter(Boolean)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Can we create this group?
|
||||
|
||||
// Do the shapes have the same parent?
|
||||
if (
|
||||
shapesToGroup.every(
|
||||
shape => shape.parentId === shapesToGroup[0].parentId
|
||||
)
|
||||
) {
|
||||
// Is the common parent a shape (not the page)?
|
||||
if (shapesToGroup[0].parentId !== pageId) {
|
||||
const commonParent = app.getShape(shapesToGroup[0].parentId);
|
||||
// Are all of the common parent's shapes selected?
|
||||
if (commonParent.children?.length === idsToGroup.length) {
|
||||
// Don't create a group if that group would be the same as the
|
||||
// existing group.
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A flattened array of shapes from the page
|
||||
const flattenedShapes = TLDR.flatten_page(app.state, pageId);
|
||||
|
||||
// A map of shapes to their index in flattendShapes
|
||||
const shapeIndexMap = Object.fromEntries(
|
||||
shapesToGroup.map(shape => [shape.id, flattenedShapes.indexOf(shape)])
|
||||
);
|
||||
|
||||
// An array of shapes in order by their index in flattendShapes
|
||||
const sortedShapes = shapesToGroup.sort(
|
||||
(a, b) => shapeIndexMap[a.id] - shapeIndexMap[b.id]
|
||||
);
|
||||
|
||||
// The parentId is always the current page
|
||||
const groupParentId = pageId; // sortedShapes[0].parentId
|
||||
|
||||
// The childIndex should be the lowest index of the selected shapes
|
||||
// with a parent that is the current page; or else the child index
|
||||
// of the lowest selected shape.
|
||||
const groupChildIndex = (
|
||||
sortedShapes.filter(shape => shape.parentId === pageId)[0] ||
|
||||
sortedShapes[0]
|
||||
).childIndex;
|
||||
|
||||
// The shape's point is the min point of its childrens' common bounds
|
||||
const groupBounds = Utils.getCommonBounds(
|
||||
shapesToGroup.map(shape => TLDR.get_bounds(shape))
|
||||
);
|
||||
|
||||
// Create the group
|
||||
beforeShapes[groupId] = undefined;
|
||||
|
||||
afterShapes[groupId] = TLDR.get_shape_util(TDShapeType.Group).create({
|
||||
id: groupId,
|
||||
childIndex: groupChildIndex,
|
||||
parentId: groupParentId,
|
||||
point: [groupBounds.minX, groupBounds.minY],
|
||||
size: [groupBounds.width, groupBounds.height],
|
||||
children: sortedShapes.map(shape => shape.id),
|
||||
workspace: app.document.id,
|
||||
});
|
||||
|
||||
// Reparent shapes to the new group
|
||||
sortedShapes.forEach((shape, index) => {
|
||||
// If the shape is part of a different group, mark the parent shape for cleanup
|
||||
if (shape.parentId !== pageId) {
|
||||
const parentShape = app.getShape(shape.parentId);
|
||||
otherEffectedGroups.push(parentShape);
|
||||
}
|
||||
|
||||
beforeShapes[shape.id] = {
|
||||
...beforeShapes[shape.id],
|
||||
parentId: shape.parentId,
|
||||
childIndex: shape.childIndex,
|
||||
};
|
||||
|
||||
afterShapes[shape.id] = {
|
||||
...afterShapes[shape.id],
|
||||
parentId: groupId,
|
||||
childIndex: index + 1,
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up effected parents
|
||||
while (otherEffectedGroups.length > 0) {
|
||||
const shape = otherEffectedGroups.pop();
|
||||
if (!shape) break;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const nextChildren = (beforeShapes[shape.id]?.children ||
|
||||
shape.children)!.filter(
|
||||
childId =>
|
||||
childId &&
|
||||
!(
|
||||
idsToGroup.includes(childId) ||
|
||||
deletedGroupIds.includes(childId)
|
||||
)
|
||||
);
|
||||
|
||||
// If the parent has no children, remove it
|
||||
if (nextChildren.length === 0) {
|
||||
beforeShapes[shape.id] = shape;
|
||||
afterShapes[shape.id] = undefined;
|
||||
|
||||
// And if that parent is part of a different group, mark it for cleanup
|
||||
// (This is necessary only when we implement nested groups.)
|
||||
if (shape.parentId !== pageId) {
|
||||
deletedGroupIds.push(shape.id);
|
||||
otherEffectedGroups.push(app.getShape(shape.parentId));
|
||||
}
|
||||
} else {
|
||||
beforeShapes[shape.id] = {
|
||||
...beforeShapes[shape.id],
|
||||
children: shape.children,
|
||||
};
|
||||
|
||||
afterShapes[shape.id] = {
|
||||
...afterShapes[shape.id],
|
||||
children: nextChildren,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This code is copied from delete.command. Create a shared helper!
|
||||
|
||||
const { bindings } = app;
|
||||
|
||||
const deletedGroupIdsSet = new Set(deletedGroupIds);
|
||||
|
||||
// We also need to delete bindings that reference the deleted shapes
|
||||
bindings.forEach(binding => {
|
||||
for (const id of [binding.toId, binding.fromId]) {
|
||||
// If the binding references a deleted shape...
|
||||
if (deletedGroupIdsSet.has(id)) {
|
||||
// Delete this binding
|
||||
beforeBindings[binding.id] = binding;
|
||||
afterBindings[binding.id] = undefined;
|
||||
|
||||
// Let's also look each the bound shape...
|
||||
const shape = app.getShape(id);
|
||||
|
||||
// If the bound shape has a handle that references the deleted binding...
|
||||
if (shape.handles) {
|
||||
Object.values(shape.handles)
|
||||
.filter(handle => handle.bindingId === binding.id)
|
||||
.forEach(handle => {
|
||||
// Save the binding reference in the before patch
|
||||
beforeShapes[id] = {
|
||||
...beforeShapes[id],
|
||||
handles: {
|
||||
...beforeShapes[id]?.handles,
|
||||
[handle.id]: { bindingId: binding.id },
|
||||
},
|
||||
};
|
||||
|
||||
// Unless we're currently deleting the shape, remove the
|
||||
// binding reference from the after patch
|
||||
if (!deletedGroupIds.includes(id)) {
|
||||
afterShapes[id] = {
|
||||
...afterShapes[id],
|
||||
handles: {
|
||||
...afterShapes[id]?.handles,
|
||||
[handle.id]: { bindingId: undefined },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'group',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: beforeShapes,
|
||||
bindings: beforeBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: afterShapes,
|
||||
bindings: beforeBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: {
|
||||
selectedIds: [groupId],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export * from './align-shapes';
|
||||
export * from './change-page';
|
||||
export * from './create-page';
|
||||
export * from './create-shapes';
|
||||
export * from './delete-page';
|
||||
export * from './delete-shapes';
|
||||
export * from './distribute-shapes';
|
||||
export * from './duplicate-page';
|
||||
export * from './duplicate-shapes';
|
||||
export * from './flip-shapes';
|
||||
export * from './group-shapes';
|
||||
export * from './move-shapes-to-page';
|
||||
export * from './reorder-shapes';
|
||||
export * from './rename-page';
|
||||
export * from './reset-bounds';
|
||||
export * from './rotate-shapes';
|
||||
export * from './stretch-shapes';
|
||||
export * from './style-shapes';
|
||||
export * from './toggle-shapes-decoration';
|
||||
export * from './toggle-shapes-prop';
|
||||
export * from './translate-shapes';
|
||||
export * from './ungroup-shapes';
|
||||
export * from './update-shapes';
|
||||
export * from './set-shapes-props';
|
||||
@@ -0,0 +1,231 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import type {
|
||||
ArrowShape,
|
||||
PagePartial,
|
||||
TldrawCommand,
|
||||
TDShape,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import { Utils, TLBounds } from '@tldraw/core';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
|
||||
export function moveShapesToPage(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
viewportBounds: TLBounds,
|
||||
fromPageId: string,
|
||||
toPageId: string
|
||||
): TldrawCommand {
|
||||
const { page } = app;
|
||||
|
||||
const fromPage: Record<string, PagePartial> = {
|
||||
before: {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
},
|
||||
after: {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
},
|
||||
};
|
||||
|
||||
const toPage: Record<string, PagePartial> = {
|
||||
before: {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
},
|
||||
after: {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Collect all the shapes to move and their keys.
|
||||
const movingShapeIds = new Set<string>();
|
||||
const shapesToMove = new Set<TDShape>();
|
||||
|
||||
ids.map(id => app.getShape(id, fromPageId))
|
||||
.filter(shape => !shape.isLocked)
|
||||
.forEach(shape => {
|
||||
movingShapeIds.add(shape.id);
|
||||
shapesToMove.add(shape);
|
||||
if (shape.children !== undefined) {
|
||||
shape.children.forEach(childId => {
|
||||
movingShapeIds.add(childId);
|
||||
shapesToMove.add(app.getShape(childId, fromPageId));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Where should we put start putting shapes on the "to" page?
|
||||
const startingChildIndex = TLDR.get_top_child_index(app.state, toPageId);
|
||||
|
||||
// Which shapes are we moving?
|
||||
const movingShapes = Array.from(shapesToMove.values());
|
||||
|
||||
movingShapes.forEach((shape, i) => {
|
||||
// Remove the shape from the fromPage
|
||||
fromPage['before'].shapes[shape.id] = shape;
|
||||
fromPage['after'].shapes[shape.id] = undefined;
|
||||
|
||||
// But the moved shape on the "to" page
|
||||
toPage['before'].shapes[shape.id] = undefined;
|
||||
toPage['after'].shapes[shape.id] = shape;
|
||||
|
||||
// If the shape's parent isn't moving too, reparent the shape to
|
||||
// the "to" page, at the top of the z stack
|
||||
if (!movingShapeIds.has(shape.parentId)) {
|
||||
toPage['after'].shapes[shape.id] = {
|
||||
...shape,
|
||||
parentId: toPageId,
|
||||
childIndex: startingChildIndex + i,
|
||||
};
|
||||
|
||||
// If the shape was in a group, then pull the shape from the
|
||||
// parent's children array.
|
||||
if (shape.parentId !== fromPageId) {
|
||||
const parent = app.getShape(shape.parentId, fromPageId);
|
||||
fromPage['before'].shapes[parent.id] = {
|
||||
children: parent.children,
|
||||
};
|
||||
|
||||
fromPage['after'].shapes[parent.id] = {
|
||||
children: parent.children!.filter(
|
||||
childId => childId !== shape.id
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle bindings that effect duplicated shapes
|
||||
Object.values(page.bindings)
|
||||
.filter(
|
||||
binding =>
|
||||
movingShapeIds.has(binding.fromId) ||
|
||||
movingShapeIds.has(binding.toId)
|
||||
)
|
||||
.forEach(binding => {
|
||||
// Always delete the binding from the from page
|
||||
|
||||
fromPage['before'].bindings[binding.id] = binding;
|
||||
fromPage['after'].bindings[binding.id] = undefined;
|
||||
|
||||
// Delete the reference from the binding's fromShape
|
||||
|
||||
const fromBoundShape = app.getShape(binding.fromId, fromPageId);
|
||||
|
||||
// Will we be copying this binding to the new page?
|
||||
|
||||
const shouldCopy =
|
||||
movingShapeIds.has(binding.fromId) &&
|
||||
movingShapeIds.has(binding.toId);
|
||||
|
||||
if (shouldCopy) {
|
||||
// Just move the binding to the new page
|
||||
toPage['before'].bindings[binding.id] = undefined;
|
||||
toPage['after'].bindings[binding.id] = binding;
|
||||
} else {
|
||||
if (movingShapeIds.has(binding.fromId)) {
|
||||
// If we are only moving the "from" shape, we need to delete
|
||||
// the binding reference from the "from" shapes handles
|
||||
const fromShape = app.getShape(binding.fromId, fromPageId);
|
||||
const handle = Object.values(fromBoundShape.handles!).find(
|
||||
handle => handle.bindingId === binding.id
|
||||
)!;
|
||||
|
||||
// Remove the handle from the shape on the toPage
|
||||
|
||||
const handleId = handle.id as keyof ArrowShape['handles'];
|
||||
|
||||
const toPageShape = toPage['after'].shapes[fromShape.id]!;
|
||||
|
||||
toPageShape.handles = {
|
||||
...toPageShape.handles,
|
||||
[handleId]: {
|
||||
...toPageShape.handles![handleId],
|
||||
bindingId: undefined,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// If we are only moving the "to" shape, we need to delete
|
||||
// the binding reference from the "from" shape's handles
|
||||
const fromShape = app.getShape(binding.fromId, fromPageId);
|
||||
const handle = Object.values(fromBoundShape.handles!).find(
|
||||
handle => handle.bindingId === binding.id
|
||||
)!;
|
||||
|
||||
fromPage['before'].shapes[fromShape.id] = {
|
||||
handles: { [handle.id]: { bindingId: binding.id } },
|
||||
};
|
||||
|
||||
fromPage['after'].shapes[fromShape.id] = {
|
||||
handles: { [handle.id]: { bindingId: undefined } },
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Finally, center camera on selection
|
||||
|
||||
const toPageState = app.state.document.pageStates[toPageId];
|
||||
|
||||
const bounds = Utils.getCommonBounds(
|
||||
movingShapes.map(shape => TLDR.get_bounds(shape))
|
||||
);
|
||||
|
||||
const zoom = TLDR.get_camera_zoom(
|
||||
viewportBounds.width < viewportBounds.height
|
||||
? (viewportBounds.width - 128) / bounds.width
|
||||
: (viewportBounds.height - 128) / bounds.height
|
||||
);
|
||||
|
||||
const mx = (viewportBounds.width - bounds.width * zoom) / 2 / zoom;
|
||||
const my = (viewportBounds.height - bounds.height * zoom) / 2 / zoom;
|
||||
|
||||
const point = Vec.toFixed(Vec.add([-bounds.minX, -bounds.minY], [mx, my]));
|
||||
|
||||
return {
|
||||
id: 'move_to_page',
|
||||
before: {
|
||||
appState: {
|
||||
currentPageId: fromPageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[fromPageId]: fromPage['before'],
|
||||
[toPageId]: toPage['before'],
|
||||
},
|
||||
pageStates: {
|
||||
[fromPageId]: { selectedIds: ids },
|
||||
[toPageId]: {
|
||||
selectedIds: toPageState.selectedIds,
|
||||
camera: toPageState.camera,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
appState: {
|
||||
currentPageId: toPageId,
|
||||
},
|
||||
document: {
|
||||
pages: {
|
||||
[fromPageId]: fromPage['after'],
|
||||
[toPageId]: toPage['after'],
|
||||
},
|
||||
pageStates: {
|
||||
[fromPageId]: { selectedIds: [] },
|
||||
[toPageId]: {
|
||||
selectedIds: ids,
|
||||
camera: {
|
||||
zoom,
|
||||
point,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function renamePage(
|
||||
app: TldrawApp,
|
||||
pageId: string,
|
||||
name: string
|
||||
): TldrawCommand {
|
||||
const { page } = app;
|
||||
|
||||
return {
|
||||
id: 'rename_page',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: { name: page.name },
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: { name: name },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
MoveType,
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function reorderShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: MoveType
|
||||
): TldrawCommand {
|
||||
const { currentPageId, page } = app;
|
||||
|
||||
// Get the unique parent ids for the selected elements
|
||||
const parentIds = new Set(ids.map(id => app.getShape(id).parentId));
|
||||
|
||||
let result: {
|
||||
before: Record<string, Partial<TDShape>>;
|
||||
after: Record<string, Partial<TDShape>>;
|
||||
} = { before: {}, after: {} };
|
||||
|
||||
let startIndex: number;
|
||||
let startChildIndex: number;
|
||||
let step: number;
|
||||
|
||||
// Collect shapes with common parents into a table under their parent id
|
||||
Array.from(parentIds.values()).forEach(parentId => {
|
||||
let sortedChildren: TDShape[] = [];
|
||||
if (parentId === page.id) {
|
||||
sortedChildren = Object.values(page.shapes).sort(
|
||||
(a, b) => a.childIndex - b.childIndex
|
||||
);
|
||||
} else {
|
||||
const parent = app.getShape(parentId);
|
||||
if (!parent.children) throw Error('No children in parent!');
|
||||
|
||||
sortedChildren = parent.children
|
||||
.map(childId => app.getShape(childId))
|
||||
.sort((a, b) => a.childIndex - b.childIndex);
|
||||
}
|
||||
|
||||
const sortedChildIds = sortedChildren.map(shape => shape.id);
|
||||
|
||||
const sortedIndicesToMove = ids
|
||||
.filter(id => sortedChildIds.includes(id))
|
||||
.map(id => sortedChildIds.indexOf(id))
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
if (sortedIndicesToMove.length === sortedChildIds.length) return;
|
||||
|
||||
switch (type) {
|
||||
case MoveType.ToBack: {
|
||||
// a b c
|
||||
// Initial 1 2 3 4 5 6 7
|
||||
// Final .25 .5 .75 1 3 6 7
|
||||
// a b c
|
||||
|
||||
// Find the lowest "open" index
|
||||
for (let i = 0; i < sortedChildIds.length; i++) {
|
||||
if (sortedIndicesToMove.includes(i)) continue;
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the lowest child index that isn't in sortedIndicesToMove
|
||||
startChildIndex = sortedChildren[startIndex].childIndex;
|
||||
|
||||
// Find the step for each additional child
|
||||
step = startChildIndex / (sortedIndicesToMove.length + 1);
|
||||
|
||||
// Get the results of moving the selected shapes below the first open index's shape
|
||||
result = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
sortedIndicesToMove
|
||||
.map(i => sortedChildren[i].id)
|
||||
.reverse(),
|
||||
(shape, i) => ({
|
||||
childIndex: startChildIndex - (i + 1) * step,
|
||||
}),
|
||||
currentPageId
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case MoveType.ToFront: {
|
||||
// a b c
|
||||
// Initial 1 2 3 4 5 6 7
|
||||
// Final 1 3 6 7 8 9 10
|
||||
// a b c
|
||||
|
||||
// Find the highest "open" index
|
||||
for (let i = sortedChildIds.length - 1; i >= 0; i--) {
|
||||
if (sortedIndicesToMove.includes(i)) continue;
|
||||
startIndex = i;
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the lowest child index that isn't in sortedIndicesToMove
|
||||
startChildIndex = sortedChildren[startIndex].childIndex;
|
||||
|
||||
// Find the step for each additional child
|
||||
step = 1;
|
||||
|
||||
// Get the results of moving the selected shapes below the first open index's shape
|
||||
result = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
sortedIndicesToMove.map(i => sortedChildren[i].id),
|
||||
(shape, i) => ({
|
||||
childIndex: startChildIndex + (i + 1),
|
||||
}),
|
||||
currentPageId
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case MoveType.Backward: {
|
||||
// a b c
|
||||
// Initial 1 2 3 4 5 6 7
|
||||
// Final .5 1 1.66 2.33 3 6 7
|
||||
// a b c
|
||||
|
||||
const indexMap: Record<string, number> = {};
|
||||
|
||||
// Starting from the top...
|
||||
for (let i = sortedChildIds.length - 1; i >= 0; i--) {
|
||||
// If we found a moving index...
|
||||
if (sortedIndicesToMove.includes(i)) {
|
||||
for (let j = i; j >= 0; j--) {
|
||||
// iterate downward until we find an open spot
|
||||
if (!sortedIndicesToMove.includes(j)) {
|
||||
// i = the index of the first closed spot
|
||||
// j = the index of the first open spot
|
||||
|
||||
const endChildIndex =
|
||||
sortedChildren[j].childIndex;
|
||||
let startChildIndex: number;
|
||||
let step: number;
|
||||
|
||||
if (j === 0) {
|
||||
// We're moving below the first child, start from
|
||||
// half of its child index.
|
||||
|
||||
startChildIndex = endChildIndex / 2;
|
||||
step = endChildIndex / 2 / (i - j + 1);
|
||||
} else {
|
||||
// Start from the child index of the child below the
|
||||
// child above.
|
||||
startChildIndex =
|
||||
sortedChildren[j - 1].childIndex;
|
||||
step =
|
||||
(endChildIndex - startChildIndex) /
|
||||
(i - j + 1);
|
||||
startChildIndex += step;
|
||||
}
|
||||
|
||||
for (let k = 0; k < i - j; k++) {
|
||||
indexMap[sortedChildren[j + k + 1].id] =
|
||||
startChildIndex + step * k;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.values(indexMap).length > 0) {
|
||||
// Get the results of moving the selected shapes below the first open index's shape
|
||||
result = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
sortedIndicesToMove.map(i => sortedChildren[i].id),
|
||||
shape => ({
|
||||
childIndex: indexMap[shape.id],
|
||||
}),
|
||||
currentPageId
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case MoveType.Forward: {
|
||||
// a b c
|
||||
// Initial 1 2 3 4 5 6 7
|
||||
// Final 1 3 3.5 6 7 8 9
|
||||
// a b c
|
||||
|
||||
const indexMap: Record<string, number> = {};
|
||||
|
||||
// Starting from the top...
|
||||
for (let i = 0; i < sortedChildIds.length; i++) {
|
||||
// If we found a moving index...
|
||||
if (sortedIndicesToMove.includes(i)) {
|
||||
// Search for the first open spot above this one
|
||||
for (let j = i; j < sortedChildIds.length; j++) {
|
||||
if (!sortedIndicesToMove.includes(j)) {
|
||||
// i = the low index of the first closed spot
|
||||
// j = the high index of the first open spot
|
||||
|
||||
startChildIndex = sortedChildren[j].childIndex;
|
||||
|
||||
const step =
|
||||
j === sortedChildIds.length - 1
|
||||
? 1
|
||||
: (sortedChildren[j + 1].childIndex -
|
||||
startChildIndex) /
|
||||
(j - i + 1);
|
||||
|
||||
for (let k = 0; k < j - i; k++) {
|
||||
indexMap[sortedChildren[i + k].id] =
|
||||
startChildIndex + step * (k + 1);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.values(indexMap).length > 0) {
|
||||
// Get the results of moving the selected shapes below the first open index's shape
|
||||
result = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
sortedIndicesToMove.map(i => sortedChildren[i].id),
|
||||
shape => ({
|
||||
childIndex: indexMap[shape.id],
|
||||
}),
|
||||
currentPageId
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'move',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: result.before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: result.after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function resetBounds(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
pageId: string
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const { before, after } = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
ids,
|
||||
shape => app.getShapeUtil(shape).onDoubleClickBoundsHandle?.(shape),
|
||||
pageId
|
||||
);
|
||||
|
||||
return {
|
||||
id: 'reset_bounds',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Utils } from '@tldraw/core';
|
||||
import type {
|
||||
TldrawCommand,
|
||||
TDShape,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
const PI2 = Math.PI * 2;
|
||||
|
||||
export function rotateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
delta = -PI2 / 4
|
||||
): TldrawCommand | void {
|
||||
const { currentPageId } = app;
|
||||
|
||||
// The shapes for the before patch
|
||||
const before: Record<string, Partial<TDShape>> = {};
|
||||
|
||||
// The shapes for the after patch
|
||||
const after: Record<string, Partial<TDShape>> = {};
|
||||
|
||||
// Find the shapes that we want to rotate.
|
||||
// We don't rotate groups: we rotate their children instead.
|
||||
const shapesToRotate = ids
|
||||
.flatMap(id => {
|
||||
const shape = app.getShape(id);
|
||||
return shape.children
|
||||
? shape.children.map(childId => app.getShape(childId))
|
||||
: shape;
|
||||
})
|
||||
.filter(shape => !shape.isLocked);
|
||||
|
||||
// Find the common center to all shapes
|
||||
// This is the point that we'll rotate around
|
||||
const origin = Utils.getBoundsCenter(
|
||||
Utils.getCommonBounds(
|
||||
shapesToRotate.map(shape => TLDR.get_bounds(shape))
|
||||
)
|
||||
);
|
||||
|
||||
// Find the rotate mutations for each shape
|
||||
shapesToRotate.forEach(shape => {
|
||||
const change = TLDR.get_rotated_shape_mutation(
|
||||
shape,
|
||||
TLDR.get_center(shape),
|
||||
origin,
|
||||
delta
|
||||
);
|
||||
if (!change) return;
|
||||
before[shape.id] = TLDR.get_before_shape(shape, change);
|
||||
after[shape.id] = change;
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'rotate',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function setShapesProps<T extends TDShape>(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
partial: Partial<T>
|
||||
): TldrawCommand {
|
||||
const { currentPageId, selectedIds } = app;
|
||||
|
||||
const initialShapes = ids
|
||||
.map(id => app.getShape<T>(id))
|
||||
.filter(shape => (partial['isLocked'] ? true : !shape.isLocked));
|
||||
|
||||
const before: Record<string, Partial<TDShape>> = {};
|
||||
const after: Record<string, Partial<TDShape>> = {};
|
||||
|
||||
const keys = Object.keys(partial) as (keyof T)[];
|
||||
|
||||
initialShapes.forEach(shape => {
|
||||
before[shape.id] = Object.fromEntries(
|
||||
keys.map(key => [key, shape[key]])
|
||||
);
|
||||
after[shape.id] = partial;
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'set_props',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: before,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: after,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type {
|
||||
ArrowShape,
|
||||
GroupShape,
|
||||
PagePartial,
|
||||
TDSnapshot,
|
||||
} from '@toeverything/components/board-types';
|
||||
|
||||
export function removeShapesFromPage(
|
||||
data: TDSnapshot,
|
||||
ids: string[],
|
||||
pageId: string
|
||||
) {
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const after: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const parentsToUpdate: GroupShape[] = [];
|
||||
const deletedIds = new Set();
|
||||
const assetsToRemove = new Set<string>();
|
||||
|
||||
// These are the shapes we're definitely going to delete
|
||||
|
||||
ids.filter(id => !TLDR.get_shape(data, id, pageId).isLocked).forEach(id => {
|
||||
deletedIds.add(id);
|
||||
const shape = TLDR.get_shape(data, id, pageId);
|
||||
before.shapes[id] = shape;
|
||||
after.shapes[id] = undefined;
|
||||
|
||||
// Also delete the shape's children
|
||||
|
||||
if (shape.children !== undefined) {
|
||||
shape.children.forEach(childId => {
|
||||
deletedIds.add(childId);
|
||||
const child = TLDR.get_shape(data, childId, pageId);
|
||||
before.shapes[childId] = child;
|
||||
after.shapes[childId] = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (shape.parentId !== pageId) {
|
||||
parentsToUpdate.push(TLDR.get_shape(data, shape.parentId, pageId));
|
||||
}
|
||||
|
||||
if (shape.assetId) {
|
||||
assetsToRemove.add(shape.assetId);
|
||||
}
|
||||
});
|
||||
|
||||
parentsToUpdate.forEach(parent => {
|
||||
if (ids.includes(parent.id)) return;
|
||||
deletedIds.add(parent.id);
|
||||
before.shapes[parent.id] = { children: parent.children };
|
||||
after.shapes[parent.id] = {
|
||||
children: parent.children.filter(id => !ids.includes(id)),
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (after.shapes[parent.id]?.children!.length === 0) {
|
||||
after.shapes[parent.id] = undefined;
|
||||
before.shapes[parent.id] = TLDR.get_shape(data, parent.id, pageId);
|
||||
}
|
||||
});
|
||||
|
||||
// Recursively check for empty parents?
|
||||
|
||||
const page = TLDR.get_page(data, pageId);
|
||||
|
||||
// We also need to delete bindings that reference the deleted shapes
|
||||
Object.values(page.bindings)
|
||||
.filter(
|
||||
binding =>
|
||||
deletedIds.has(binding.fromId) || deletedIds.has(binding.toId)
|
||||
)
|
||||
.forEach(binding => {
|
||||
for (const id of [binding.toId, binding.fromId]) {
|
||||
// If the binding references a deleted shape...
|
||||
if (after.shapes[id] === undefined) {
|
||||
// Delete this binding
|
||||
before.bindings[binding.id] = binding;
|
||||
after.bindings[binding.id] = undefined;
|
||||
|
||||
// Let's also look each the bound shape...
|
||||
const shape = page.shapes[id];
|
||||
|
||||
// If the bound shape has a handle that references the deleted binding...
|
||||
if (shape && shape.handles) {
|
||||
Object.values(shape.handles)
|
||||
.filter(handle => handle.bindingId === binding.id)
|
||||
.forEach(handle => {
|
||||
// Save the binding reference in the before patch
|
||||
before.shapes[id] = {
|
||||
...before.shapes[id],
|
||||
handles: {
|
||||
...before.shapes[id]?.handles,
|
||||
[handle.id]: {
|
||||
...before.shapes[id]?.handles?.[
|
||||
handle.id as keyof ArrowShape['handles']
|
||||
],
|
||||
bindingId: binding.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Unless we're currently deleting the shape, remove the
|
||||
// binding reference from the after patch
|
||||
if (!deletedIds.has(id)) {
|
||||
after.shapes[id] = {
|
||||
...after.shapes[id],
|
||||
handles: {
|
||||
...after.shapes[id]?.handles,
|
||||
[handle.id]: {
|
||||
...after.shapes[id]?.handles?.[
|
||||
handle.id as keyof ArrowShape['handles']
|
||||
],
|
||||
bindingId: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// If any other shapes are using the deleted assets, don't remove them
|
||||
Object.values(data.document.pages)
|
||||
.flatMap(page => Object.values(page.shapes))
|
||||
.forEach(shape => {
|
||||
if (
|
||||
'assetId' in shape &&
|
||||
shape.assetId &&
|
||||
!deletedIds.has(shape.id)
|
||||
) {
|
||||
assetsToRemove.delete(shape.assetId);
|
||||
}
|
||||
});
|
||||
|
||||
return { before, after, assetsToRemove: Array.from(assetsToRemove) };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { TLBoundsCorner, Utils } from '@tldraw/core';
|
||||
import { StretchType, TDShapeType } from '@toeverything/components/board-types';
|
||||
import type { TldrawCommand } from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function stretchShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
type: StretchType
|
||||
): TldrawCommand {
|
||||
const { currentPageId, selectedIds } = app;
|
||||
|
||||
const initialShapes = ids.map(id => app.getShape(id));
|
||||
|
||||
const boundsForShapes = initialShapes.map(shape => TLDR.get_bounds(shape));
|
||||
|
||||
const commonBounds = Utils.getCommonBounds(boundsForShapes);
|
||||
|
||||
const idsToMutate = ids
|
||||
.flatMap(id => {
|
||||
const shape = app.getShape(id);
|
||||
return shape.children ? shape.children : shape.id;
|
||||
})
|
||||
.filter(id => !app.getShape(id).isLocked);
|
||||
|
||||
const { before, after } = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
idsToMutate,
|
||||
shape => {
|
||||
const bounds = TLDR.get_bounds(shape);
|
||||
|
||||
switch (type) {
|
||||
case StretchType.Horizontal: {
|
||||
const newBounds = {
|
||||
...bounds,
|
||||
minX: commonBounds.minX,
|
||||
maxX: commonBounds.maxX,
|
||||
width: commonBounds.width,
|
||||
};
|
||||
|
||||
return TLDR.get_shape_util(shape).transformSingle(
|
||||
shape,
|
||||
newBounds,
|
||||
{
|
||||
type: TLBoundsCorner.TopLeft,
|
||||
scaleX: newBounds.width / bounds.width,
|
||||
scaleY: 1,
|
||||
initialShape: shape,
|
||||
transformOrigin: [0.5, 0.5],
|
||||
}
|
||||
);
|
||||
}
|
||||
case StretchType.Vertical: {
|
||||
const newBounds = {
|
||||
...bounds,
|
||||
minY: commonBounds.minY,
|
||||
maxY: commonBounds.maxY,
|
||||
height: commonBounds.height,
|
||||
};
|
||||
|
||||
return TLDR.get_shape_util(shape).transformSingle(
|
||||
shape,
|
||||
newBounds,
|
||||
{
|
||||
type: TLBoundsCorner.TopLeft,
|
||||
scaleX: 1,
|
||||
scaleY: newBounds.height / bounds.height,
|
||||
initialShape: shape,
|
||||
transformOrigin: [0.5, 0.5],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
currentPageId
|
||||
);
|
||||
|
||||
initialShapes.forEach(shape => {
|
||||
if (shape.type === TDShapeType.Group) {
|
||||
delete before[shape.id];
|
||||
delete after[shape.id];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'stretch',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: before },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: after },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Patch,
|
||||
ShapeStyles,
|
||||
TldrawCommand,
|
||||
TDShape,
|
||||
// TDShapeType,
|
||||
// TextShape
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function styleShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
changes: Partial<ShapeStyles>
|
||||
): TldrawCommand {
|
||||
const { currentPageId, selectedIds } = app;
|
||||
|
||||
const shapeIdsToMutate = ids
|
||||
.flatMap(id => TLDR.get_document_branch(app.state, id, currentPageId))
|
||||
.filter(id => !app.getShape(id).isLocked);
|
||||
|
||||
const beforeShapes: Record<string, Patch<TDShape>> = {};
|
||||
const afterShapes: Record<string, Patch<TDShape>> = {};
|
||||
|
||||
shapeIdsToMutate
|
||||
.map(id => app.getShape(id))
|
||||
.filter(shape => !shape.isLocked)
|
||||
.forEach(shape => {
|
||||
beforeShapes[shape.id] = {
|
||||
style: {
|
||||
...Object.fromEntries(
|
||||
Object.keys(changes).map(key => [
|
||||
key,
|
||||
shape.style[key as keyof typeof shape.style],
|
||||
])
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
afterShapes[shape.id] = {
|
||||
style: changes,
|
||||
};
|
||||
|
||||
// if (shape.type === TDShapeType.Text) {
|
||||
// beforeShapes[shape.id].point = shape.point;
|
||||
// afterShapes[shape.id].point = Vec.toFixed(
|
||||
// Vec.add(
|
||||
// shape.point,
|
||||
// Vec.sub(
|
||||
// app.getShapeUtil(shape).getCenter(shape),
|
||||
// app.getShapeUtil(shape).getCenter({
|
||||
// ...shape,
|
||||
// style: { ...shape.style, ...changes }
|
||||
// } as TextShape)
|
||||
// )
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'style',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: beforeShapes,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
appState: {
|
||||
currentStyle: { ...app.appState.currentStyle },
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: afterShapes,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
appState: {
|
||||
currentStyle: changes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Decoration } from '@toeverything/components/board-types';
|
||||
import type {
|
||||
Patch,
|
||||
ArrowShape,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function toggleShapesDecoration(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
decorationId: 'start' | 'end'
|
||||
): TldrawCommand {
|
||||
const { currentPageId, selectedIds } = app;
|
||||
|
||||
const beforeShapes: Record<string, Patch<ArrowShape>> = Object.fromEntries(
|
||||
ids.map(id => [
|
||||
id,
|
||||
{
|
||||
decorations: {
|
||||
[decorationId]:
|
||||
app.getShape<ArrowShape>(id).decorations?.[
|
||||
decorationId
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const afterShapes: Record<string, Patch<ArrowShape>> = Object.fromEntries(
|
||||
ids
|
||||
.filter(id => !app.getShape(id).isLocked)
|
||||
.map(id => [
|
||||
id,
|
||||
{
|
||||
decorations: {
|
||||
[decorationId]: app.getShape<ArrowShape>(id)
|
||||
.decorations?.[decorationId]
|
||||
? undefined
|
||||
: Decoration.Arrow,
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
return {
|
||||
id: 'toggle_decorations',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: beforeShapes },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: { shapes: afterShapes },
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function toggleShapesProp(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
prop: keyof TDShape
|
||||
): TldrawCommand {
|
||||
const { currentPageId } = app;
|
||||
|
||||
const initialShapes = ids
|
||||
.map(id => app.getShape(id))
|
||||
.filter(shape => (prop === 'isLocked' ? true : !shape.isLocked));
|
||||
|
||||
const isAllToggled = initialShapes.every(shape => shape[prop]);
|
||||
|
||||
const before: Record<string, Partial<TDShape>> = {};
|
||||
const after: Record<string, Partial<TDShape>> = {};
|
||||
|
||||
initialShapes.forEach(shape => {
|
||||
before[shape.id] = { [prop]: shape[prop] };
|
||||
after[shape.id] = { [prop]: !isAllToggled };
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'toggle',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: before,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: {
|
||||
shapes: after,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Vec } from '@tldraw/vec';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type {
|
||||
TldrawCommand,
|
||||
PagePartial,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function translateShapes(
|
||||
app: TldrawApp,
|
||||
ids: string[],
|
||||
delta: number[]
|
||||
): TldrawCommand {
|
||||
const { currentPageId, selectedIds } = app;
|
||||
|
||||
// Clear session cache
|
||||
app.rotationInfo.selectedIds = [...selectedIds];
|
||||
|
||||
const before: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const after: PagePartial = {
|
||||
shapes: {},
|
||||
bindings: {},
|
||||
};
|
||||
|
||||
const idsToMutate = ids
|
||||
.flatMap(id => {
|
||||
const shape = app.getShape(id);
|
||||
return shape.children ? shape.children : shape.id;
|
||||
})
|
||||
.filter(id => !app.getShape(id).isLocked);
|
||||
|
||||
const change = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
idsToMutate,
|
||||
shape => ({
|
||||
point: Vec.toFixed(Vec.add(shape.point, delta)),
|
||||
}),
|
||||
currentPageId
|
||||
);
|
||||
|
||||
before.shapes = change.before;
|
||||
after.shapes = change.after;
|
||||
|
||||
// Delete bindings from nudged shapes, unless both bound and bound-to shapes are selected
|
||||
const bindingsToDelete = TLDR.get_bindings(app.state, currentPageId).filter(
|
||||
binding => ids.includes(binding.fromId) && !ids.includes(binding.toId)
|
||||
);
|
||||
|
||||
bindingsToDelete.forEach(binding => {
|
||||
before.bindings[binding.id] = binding;
|
||||
after.bindings[binding.id] = undefined;
|
||||
|
||||
for (const id of [binding.toId, binding.fromId]) {
|
||||
// Let's also look at the bound shape...
|
||||
const shape = app.getShape(id);
|
||||
|
||||
if (!shape.handles) continue;
|
||||
|
||||
// If the bound shape has a handle that references the deleted binding, delete that reference
|
||||
|
||||
Object.values(shape.handles)
|
||||
.filter(handle => handle.bindingId === binding.id)
|
||||
.forEach(handle => {
|
||||
before.shapes[id] = {
|
||||
...before.shapes[id],
|
||||
handles: {
|
||||
...before.shapes[id]?.handles,
|
||||
[handle.id]: { bindingId: binding.id },
|
||||
},
|
||||
};
|
||||
after.shapes[id] = {
|
||||
...after.shapes[id],
|
||||
handles: {
|
||||
...after.shapes[id]?.handles,
|
||||
[handle.id]: { bindingId: undefined },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'translate',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: before,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[currentPageId]: after,
|
||||
},
|
||||
pageStates: {
|
||||
[currentPageId]: {
|
||||
selectedIds: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type {
|
||||
Patch,
|
||||
GroupShape,
|
||||
TDBinding,
|
||||
TDShape,
|
||||
TldrawCommand,
|
||||
} from '@toeverything/components/board-types';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function ungroupShapes(
|
||||
app: TldrawApp,
|
||||
selectedIds: string[],
|
||||
groupShapes: GroupShape[],
|
||||
pageId: string
|
||||
): TldrawCommand | undefined {
|
||||
const { bindings } = app;
|
||||
|
||||
const beforeShapes: Record<string, Patch<TDShape | undefined>> = {};
|
||||
const afterShapes: Record<string, Patch<TDShape | undefined>> = {};
|
||||
|
||||
const beforeBindings: Record<string, Patch<TDBinding | undefined>> = {};
|
||||
const afterBindings: Record<string, Patch<TDBinding | undefined>> = {};
|
||||
|
||||
const beforeSelectedIds = selectedIds;
|
||||
const afterSelectedIds = selectedIds.filter(
|
||||
id => !groupShapes.find(shape => shape.id === id)
|
||||
);
|
||||
|
||||
// The group shape
|
||||
groupShapes
|
||||
.filter(shape => !shape.isLocked)
|
||||
.forEach(groupShape => {
|
||||
const shapesToReparent: TDShape[] = [];
|
||||
const deletedGroupIds: string[] = [];
|
||||
|
||||
// Remove the group shape in the next state
|
||||
beforeShapes[groupShape.id] = groupShape;
|
||||
afterShapes[groupShape.id] = undefined;
|
||||
|
||||
// Select its children in the next state
|
||||
groupShape.children.forEach(id => {
|
||||
afterSelectedIds.push(id);
|
||||
const shape = app.getShape(id, pageId);
|
||||
shapesToReparent.push(shape);
|
||||
});
|
||||
|
||||
// We'll start placing the shapes at this childIndex
|
||||
const startingChildIndex = groupShape.childIndex;
|
||||
|
||||
// And we'll need to fit them under this child index
|
||||
const endingChildIndex = TLDR.get_child_index_above(
|
||||
app.state,
|
||||
groupShape.id,
|
||||
pageId
|
||||
);
|
||||
|
||||
const step =
|
||||
(endingChildIndex - startingChildIndex) /
|
||||
shapesToReparent.length;
|
||||
|
||||
// An array of shapes in order by their child index
|
||||
const sortedShapes = shapesToReparent.sort(
|
||||
(a, b) => a.childIndex - b.childIndex
|
||||
);
|
||||
|
||||
// Reparent shapes to the page
|
||||
sortedShapes.forEach((shape, index) => {
|
||||
beforeShapes[shape.id] = {
|
||||
parentId: shape.parentId,
|
||||
childIndex: shape.childIndex,
|
||||
};
|
||||
|
||||
afterShapes[shape.id] = {
|
||||
parentId: pageId,
|
||||
childIndex: startingChildIndex + step * index,
|
||||
};
|
||||
});
|
||||
|
||||
// We also need to delete bindings that reference the deleted shapes
|
||||
bindings
|
||||
.filter(
|
||||
binding =>
|
||||
binding.toId === groupShape.id ||
|
||||
binding.fromId === groupShape.id
|
||||
)
|
||||
.forEach(binding => {
|
||||
for (const id of [binding.toId, binding.fromId]) {
|
||||
// If the binding references the deleted group...
|
||||
if (afterShapes[id] === undefined) {
|
||||
// Delete the binding
|
||||
beforeBindings[binding.id] = binding;
|
||||
afterBindings[binding.id] = undefined;
|
||||
|
||||
// Let's also look each the bound shape...
|
||||
const shape = app.getShape(id, pageId);
|
||||
|
||||
// If the bound shape has a handle that references the deleted binding...
|
||||
if (shape.handles) {
|
||||
Object.values(shape.handles)
|
||||
.filter(
|
||||
handle =>
|
||||
handle.bindingId === binding.id
|
||||
)
|
||||
.forEach(handle => {
|
||||
// Save the binding reference in the before patch
|
||||
beforeShapes[id] = {
|
||||
...beforeShapes[id],
|
||||
handles: {
|
||||
...beforeShapes[id]?.handles,
|
||||
[handle.id]: {
|
||||
bindingId: binding.id,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Unless we're currently deleting the shape, remove the
|
||||
// binding reference from the after patch
|
||||
if (!deletedGroupIds.includes(id)) {
|
||||
afterShapes[id] = {
|
||||
...afterShapes[id],
|
||||
handles: {
|
||||
...afterShapes[id]?.handles,
|
||||
[handle.id]: {
|
||||
bindingId: undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
id: 'ungroup',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: beforeShapes,
|
||||
bindings: beforeBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: {
|
||||
selectedIds: beforeSelectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: afterShapes,
|
||||
bindings: beforeBindings,
|
||||
},
|
||||
},
|
||||
pageStates: {
|
||||
[pageId]: {
|
||||
selectedIds: afterSelectedIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
TldrawCommand,
|
||||
TDShape,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export function updateShapes(
|
||||
app: TldrawApp,
|
||||
updates: ({ id: string } & Partial<TDShape>)[],
|
||||
pageId: string
|
||||
): TldrawCommand {
|
||||
const ids = updates.map(update => update.id);
|
||||
|
||||
const change = TLDR.mutate_shapes(
|
||||
app.state,
|
||||
ids.filter(id => !app.getShape(id, pageId).isLocked),
|
||||
(shape, i) => updates[i],
|
||||
pageId
|
||||
);
|
||||
|
||||
return {
|
||||
id: 'update',
|
||||
before: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: change.before,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
after: {
|
||||
document: {
|
||||
pages: {
|
||||
[pageId]: {
|
||||
shapes: change.after,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# components-board-draw
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test components-board-draw` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: 'components-board-draw',
|
||||
preset: '../../../jest.preset.js',
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'babel-jest',
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
|
||||
coverageDirectory: '../../../coverage/libs/components/board-draw',
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@toeverything/components/board-draw",
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mui/icons-material": "^5.8.0",
|
||||
"@tldraw/core": "^1.12.0",
|
||||
"@tldraw/intersect": "^1.7.1",
|
||||
"@tldraw/vec": "^1.7.0",
|
||||
"mobx": "^6.6.0",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-hotkeys-hook": "^3.4.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/components/board-draw/src",
|
||||
"projectType": "library",
|
||||
"tags": ["components"],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/components/board-draw",
|
||||
"tsConfig": "libs/components/board-draw/tsconfig.lib.json",
|
||||
"project": "libs/components/board-draw/package.json",
|
||||
"entryFile": "libs/components/board-draw/src/index.ts",
|
||||
"external": ["react/jsx-runtime"],
|
||||
"rollupConfig": "libs/rollup.config.cjs",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "libs/components/board-draw/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"libs/components/board-draw/**/*.{ts,tsx,js,jsx}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["coverage/libs/components/board-draw"],
|
||||
"options": {
|
||||
"jestConfig": "libs/components/board-draw/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/* eslint-disable max-lines */
|
||||
import * as React from 'react';
|
||||
import { Renderer } from '@tldraw/core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
TDDocument,
|
||||
TDStatus,
|
||||
GRID_SIZE,
|
||||
TDMeta,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
TldrawApp,
|
||||
TldrawAppCtorProps,
|
||||
TLDR,
|
||||
} from '@toeverything/components/board-state';
|
||||
import {
|
||||
TldrawContext,
|
||||
useStylesheet,
|
||||
useKeyboardShortcuts,
|
||||
useTldrawApp,
|
||||
} from './hooks';
|
||||
import { shapeUtils } from '@toeverything/components/board-shapes';
|
||||
import { ToolsPanel } from './components/tools-panel';
|
||||
// import { TopPanel } from '~components/TopPanel';
|
||||
import { ContextMenu } from './components/context-menu';
|
||||
// import { FocusButton } from '~components/FocusButton';
|
||||
import { Loading } from './components/loading';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorFallback } from './components/error-fallback';
|
||||
import { ZoomBar } from './components/zoom-bar';
|
||||
import { CommandPanel } from './components/command-panel';
|
||||
|
||||
export interface TldrawProps extends TldrawAppCtorProps {
|
||||
/**
|
||||
* (optional) If provided, the component will load / persist state under this key.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* (optional) The document to load or update from.
|
||||
*/
|
||||
document?: TDDocument;
|
||||
|
||||
/**
|
||||
* (optional) The current page id.
|
||||
*/
|
||||
currentPageId?: string;
|
||||
|
||||
/**
|
||||
* (optional) Whether the editor should immediately receive focus. Defaults to true.
|
||||
*/
|
||||
autofocus?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the menu UI.
|
||||
*/
|
||||
showMenu?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the multiplayer menu.
|
||||
*/
|
||||
showMultiplayerMenu?: boolean;
|
||||
/**
|
||||
* (optional) Whether to show the pages UI.
|
||||
*/
|
||||
showPages?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the styles UI.
|
||||
*/
|
||||
showStyles?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the zoom UI.
|
||||
*/
|
||||
showZoom?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the tools UI.
|
||||
*/
|
||||
showTools?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show a sponsor link for Tldraw.
|
||||
*/
|
||||
showSponsorLink?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the UI.
|
||||
*/
|
||||
showUI?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to the document should be read only.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to to show the app's dark mode UI.
|
||||
*/
|
||||
darkMode?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) If provided, image/video componnets will be disabled.
|
||||
*
|
||||
* Warning: Keeping this enabled for multiplayer applications without provifing a storage
|
||||
* bucket based solution will cause massive base64 string to be written to the liveblocks room.
|
||||
*/
|
||||
disableAssets?: boolean;
|
||||
}
|
||||
|
||||
export function Tldraw({
|
||||
id,
|
||||
document,
|
||||
currentPageId,
|
||||
autofocus = true,
|
||||
showMenu = true,
|
||||
showMultiplayerMenu = true,
|
||||
showPages = true,
|
||||
showTools = true,
|
||||
showZoom = true,
|
||||
showStyles = true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
showUI = true,
|
||||
readOnly = false,
|
||||
disableAssets = false,
|
||||
darkMode = false,
|
||||
showSponsorLink,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
}: TldrawProps) {
|
||||
const [sId, set_sid] = React.useState(id);
|
||||
|
||||
// Create a new app when the component mounts.
|
||||
const [app, setApp] = React.useState(() => {
|
||||
const app = new TldrawApp({
|
||||
id,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
});
|
||||
return app;
|
||||
});
|
||||
|
||||
// Create a new app if the `id` prop changes.
|
||||
React.useLayoutEffect(() => {
|
||||
if (id === sId) return;
|
||||
const newApp = new TldrawApp({
|
||||
id,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
});
|
||||
|
||||
set_sid(id);
|
||||
|
||||
setApp(newApp);
|
||||
}, [sId, id]);
|
||||
|
||||
// Update the document if the `document` prop changes but the ids,
|
||||
// are the same, or else load a new document if the ids are different.
|
||||
React.useEffect(() => {
|
||||
if (!document) return;
|
||||
|
||||
if (document.id === app.document.id) {
|
||||
app.updateDocument(document);
|
||||
} else {
|
||||
app.loadDocument(document);
|
||||
}
|
||||
}, [document, app]);
|
||||
|
||||
// Disable assets when the `disableAssets` prop changes.
|
||||
React.useEffect(() => {
|
||||
app.setDisableAssets(disableAssets);
|
||||
}, [app, disableAssets]);
|
||||
|
||||
// Change the page when the `currentPageId` prop changes.
|
||||
React.useEffect(() => {
|
||||
if (!currentPageId) return;
|
||||
app.changePage(currentPageId);
|
||||
}, [currentPageId, app]);
|
||||
|
||||
// Toggle the app's readOnly mode when the `readOnly` prop changes.
|
||||
React.useEffect(() => {
|
||||
app.readOnly = readOnly;
|
||||
}, [app, readOnly]);
|
||||
|
||||
// Toggle the app's darkMode when the `darkMode` prop changes.
|
||||
React.useEffect(() => {
|
||||
if (darkMode !== app.settings.isDarkMode) {
|
||||
app.toggleDarkMode();
|
||||
}
|
||||
}, [app, darkMode]);
|
||||
|
||||
// Update the app's callbacks when any callback changes.
|
||||
React.useEffect(() => {
|
||||
app.callbacks = callbacks || {};
|
||||
}, [app, callbacks]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!window.document?.fonts) return;
|
||||
|
||||
function refreshBoundingBoxes() {
|
||||
app.refreshBoundingBoxes();
|
||||
}
|
||||
window.document.fonts.addEventListener(
|
||||
'loadingdone',
|
||||
refreshBoundingBoxes
|
||||
);
|
||||
return () => {
|
||||
window.document.fonts.removeEventListener(
|
||||
'loadingdone',
|
||||
refreshBoundingBoxes
|
||||
);
|
||||
};
|
||||
}, [app]);
|
||||
|
||||
// Use the `key` to ensure that new selector hooks are made when the id changes
|
||||
return (
|
||||
<TldrawContext.Provider value={app}>
|
||||
<InnerTldraw
|
||||
key={sId || 'Tldraw'}
|
||||
id={sId}
|
||||
autofocus={autofocus}
|
||||
showPages={showPages}
|
||||
showMenu={showMenu}
|
||||
showMultiplayerMenu={showMultiplayerMenu}
|
||||
showStyles={showStyles}
|
||||
showZoom={showZoom}
|
||||
showTools={showTools}
|
||||
showUI={showUI}
|
||||
showSponsorLink={showSponsorLink}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</TldrawContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface InnerTldrawProps {
|
||||
id?: string;
|
||||
autofocus: boolean;
|
||||
readOnly: boolean;
|
||||
showPages: boolean;
|
||||
showMenu: boolean;
|
||||
showMultiplayerMenu: boolean;
|
||||
showZoom: boolean;
|
||||
showStyles: boolean;
|
||||
showUI: boolean;
|
||||
showTools: boolean;
|
||||
showSponsorLink?: boolean;
|
||||
}
|
||||
|
||||
const InnerTldraw = React.memo(function InnerTldraw({
|
||||
id,
|
||||
autofocus,
|
||||
showPages,
|
||||
showMenu,
|
||||
showMultiplayerMenu,
|
||||
showZoom,
|
||||
showStyles,
|
||||
showTools,
|
||||
showSponsorLink,
|
||||
readOnly,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
showUI,
|
||||
}: InnerTldrawProps) {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const rWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const state = app.useStore();
|
||||
|
||||
const { document, settings, appState, room } = state;
|
||||
const isSelecting = state.appState.activeTool === 'select';
|
||||
|
||||
const page = document.pages[appState.currentPageId];
|
||||
const pageState = document.pageStates[page.id];
|
||||
const assets = document.assets;
|
||||
const { selectedIds } = pageState;
|
||||
|
||||
const isHideBoundsShape =
|
||||
selectedIds.length === 1 &&
|
||||
page.shapes[selectedIds[0]] &&
|
||||
TLDR.get_shape_util(page.shapes[selectedIds[0]].type).hideBounds;
|
||||
|
||||
const isHideResizeHandlesShape =
|
||||
selectedIds.length === 1 &&
|
||||
page.shapes[selectedIds[0]] &&
|
||||
TLDR.get_shape_util(page.shapes[selectedIds[0]].type).hideResizeHandles;
|
||||
|
||||
// Custom rendering meta, with dark mode for shapes
|
||||
const meta: TDMeta = React.useMemo(() => {
|
||||
return { isDarkMode: settings.isDarkMode, app };
|
||||
}, [settings.isDarkMode, app]);
|
||||
|
||||
const showDashedBrush = settings.isCadSelectMode
|
||||
? !appState.selectByContain
|
||||
: appState.selectByContain;
|
||||
|
||||
// Custom theme, based on darkmode
|
||||
const theme = React.useMemo(() => {
|
||||
const { selectByContain } = appState;
|
||||
const { isDarkMode, isCadSelectMode } = settings;
|
||||
|
||||
if (isDarkMode) {
|
||||
const brushBase = isCadSelectMode
|
||||
? selectByContain
|
||||
? '69, 155, 255'
|
||||
: '105, 209, 73'
|
||||
: '180, 180, 180';
|
||||
return {
|
||||
brushFill: `rgba(${brushBase}, ${
|
||||
isCadSelectMode ? 0.08 : 0.05
|
||||
})`,
|
||||
brushStroke: `rgba(${brushBase}, ${
|
||||
isCadSelectMode ? 0.5 : 0.25
|
||||
})`,
|
||||
brushDashStroke: `rgba(${brushBase}, .6)`,
|
||||
selected: 'rgba(38, 150, 255, 1.000)',
|
||||
selectFill: 'rgba(38, 150, 255, 0.05)',
|
||||
background: '#212529',
|
||||
foreground: '#49555f',
|
||||
};
|
||||
}
|
||||
|
||||
const brushBase = isCadSelectMode
|
||||
? selectByContain
|
||||
? '0, 89, 242'
|
||||
: '51, 163, 23'
|
||||
: '0,0,0';
|
||||
|
||||
return {
|
||||
brushFill: `rgba(${brushBase}, ${isCadSelectMode ? 0.08 : 0.05})`,
|
||||
brushStroke: `rgba(${brushBase}, ${isCadSelectMode ? 0.4 : 0.25})`,
|
||||
brushDashStroke: `rgba(${brushBase}, .6)`,
|
||||
background: '#fff',
|
||||
};
|
||||
}, [
|
||||
settings.isDarkMode,
|
||||
settings.isCadSelectMode,
|
||||
appState.selectByContain,
|
||||
]);
|
||||
|
||||
const isInSession = app.session !== undefined;
|
||||
|
||||
// Hide bounds when not using the select tool, or when the only selected shape has handles
|
||||
const hideBounds =
|
||||
(isInSession && app.session?.constructor.name !== 'BrushSession') ||
|
||||
!isSelecting ||
|
||||
isHideBoundsShape ||
|
||||
!!pageState.editingId;
|
||||
|
||||
// Hide bounds when not using the select tool, or when in session
|
||||
const hideHandles = isInSession || !isSelecting;
|
||||
|
||||
// Hide indicators when not using the select tool, or when in session
|
||||
const hideIndicators =
|
||||
(isInSession && state.appState.status !== TDStatus.Brushing) ||
|
||||
!isSelecting;
|
||||
|
||||
const hideCloneHandles =
|
||||
isInSession ||
|
||||
!isSelecting ||
|
||||
!settings.showCloneHandles ||
|
||||
pageState.camera.zoom < 0.2;
|
||||
return (
|
||||
<StyledLayout
|
||||
ref={rWrapper}
|
||||
tabIndex={-0}
|
||||
penColor={app?.appState?.currentStyle?.stroke}
|
||||
>
|
||||
<Loading />
|
||||
<OneOff focusableRef={rWrapper} autofocus={autofocus} />
|
||||
<ContextMenu>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Renderer
|
||||
id={id}
|
||||
containerRef={rWrapper}
|
||||
shapeUtils={shapeUtils}
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
assets={assets}
|
||||
snapLines={appState.snapLines}
|
||||
eraseLine={appState.laserLine}
|
||||
grid={GRID_SIZE}
|
||||
users={room?.users}
|
||||
userId={room?.userId}
|
||||
theme={theme}
|
||||
meta={meta as unknown as Record<string, unknown>}
|
||||
hideBounds={hideBounds}
|
||||
hideHandles={hideHandles}
|
||||
hideResizeHandles={isHideResizeHandlesShape}
|
||||
hideIndicators={hideIndicators}
|
||||
hideBindingHandles={!settings.showBindingHandles}
|
||||
hideCloneHandles={hideCloneHandles}
|
||||
hideRotateHandles={!settings.showRotateHandles}
|
||||
hideGrid={!settings.showGrid}
|
||||
showDashedBrush={showDashedBrush}
|
||||
performanceMode={app.session?.performanceMode}
|
||||
onPinchStart={app.onPinchStart}
|
||||
onPinchEnd={app.onPinchEnd}
|
||||
onPinch={app.onPinch}
|
||||
onPan={app.onPan}
|
||||
onZoom={app.onZoom}
|
||||
onPointerDown={app.onPointerDown}
|
||||
onPointerMove={app.onPointerMove}
|
||||
onPointerUp={app.onPointerUp}
|
||||
onPointCanvas={app.onPointCanvas}
|
||||
onDoubleClickCanvas={app.onDoubleClickCanvas}
|
||||
onRightPointCanvas={app.onRightPointCanvas}
|
||||
onDragCanvas={app.onDragCanvas}
|
||||
onReleaseCanvas={app.onReleaseCanvas}
|
||||
onPointShape={app.onPointShape}
|
||||
onDoubleClickShape={app.onDoubleClickShape}
|
||||
onRightPointShape={app.onRightPointShape}
|
||||
onDragShape={app.onDragShape}
|
||||
onHoverShape={app.onHoverShape}
|
||||
onUnhoverShape={app.onUnhoverShape}
|
||||
onReleaseShape={app.onReleaseShape}
|
||||
onPointBounds={app.onPointBounds}
|
||||
onDoubleClickBounds={app.onDoubleClickBounds}
|
||||
onRightPointBounds={app.onRightPointBounds}
|
||||
onDragBounds={app.onDragBounds}
|
||||
onHoverBounds={app.onHoverBounds}
|
||||
onUnhoverBounds={app.onUnhoverBounds}
|
||||
onReleaseBounds={app.onReleaseBounds}
|
||||
onPointBoundsHandle={app.onPointBoundsHandle}
|
||||
onDoubleClickBoundsHandle={
|
||||
app.onDoubleClickBoundsHandle
|
||||
}
|
||||
onRightPointBoundsHandle={app.onRightPointBoundsHandle}
|
||||
onDragBoundsHandle={app.onDragBoundsHandle}
|
||||
onHoverBoundsHandle={app.onHoverBoundsHandle}
|
||||
onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
|
||||
onReleaseBoundsHandle={app.onReleaseBoundsHandle}
|
||||
onPointHandle={app.onPointHandle}
|
||||
onDoubleClickHandle={app.onDoubleClickHandle}
|
||||
onRightPointHandle={app.onRightPointHandle}
|
||||
onDragHandle={app.onDragHandle}
|
||||
onHoverHandle={app.onHoverHandle}
|
||||
onUnhoverHandle={app.onUnhoverHandle}
|
||||
onReleaseHandle={app.onReleaseHandle}
|
||||
onError={app.onError}
|
||||
onRenderCountChange={app.onRenderCountChange}
|
||||
onShapeChange={app.onShapeChange}
|
||||
onShapeBlur={app.onShapeBlur}
|
||||
onShapeClone={app.onShapeClone}
|
||||
onBoundsChange={app.updateBounds}
|
||||
onKeyDown={app.onKeyDown}
|
||||
onKeyUp={app.onKeyUp}
|
||||
onDragOver={app.onDragOver}
|
||||
onDrop={app.onDrop}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</ContextMenu>
|
||||
{showUI && (
|
||||
<StyledUI>
|
||||
<>
|
||||
<StyledSpacer />
|
||||
{showTools && !readOnly && <ToolsPanel app={app} />}
|
||||
<CommandPanel app={app} />
|
||||
</>
|
||||
</StyledUI>
|
||||
)}
|
||||
<ZoomBar />
|
||||
</StyledLayout>
|
||||
);
|
||||
});
|
||||
|
||||
const OneOff = React.memo(function OneOff({
|
||||
focusableRef,
|
||||
autofocus,
|
||||
}: {
|
||||
autofocus?: boolean;
|
||||
focusableRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
useKeyboardShortcuts(focusableRef);
|
||||
useStylesheet();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autofocus) {
|
||||
focusableRef.current?.focus();
|
||||
}
|
||||
}, [autofocus]);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const StyledLayout = styled('div')<{ penColor: string }>(
|
||||
({ theme, penColor }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
|
||||
'& .tl-container': {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
'.tl-erase-line': {
|
||||
fill: penColor,
|
||||
},
|
||||
},
|
||||
|
||||
'& input, textarea, button, select, label, button': {
|
||||
webkitTouchCallout: 'none',
|
||||
webkitUserSelect: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
tapHighlightColor: 'transparent',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const StyledUI = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
padding: '8px 8px 0 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
'& > *': {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
});
|
||||
|
||||
const StyledSpacer = styled('div')({
|
||||
flexGrow: 2,
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { Popover, Tooltip, IconButton } from '@toeverything/components/ui';
|
||||
import {
|
||||
BorderColorNoneIcon,
|
||||
BorderColorDuotoneIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
import { Palette } from '../palette';
|
||||
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
const _colors = [
|
||||
'none',
|
||||
'#F1675E',
|
||||
'#FF7F22',
|
||||
'#FFCB45',
|
||||
'#40DF9B',
|
||||
'#13D9E3',
|
||||
'#3E6FDB',
|
||||
'#7352F1',
|
||||
'#3A4C5C',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
const _getIconRenderColor = (shapes: TDShape[]) => {
|
||||
const counted = countBy(shapes, shape => shape.style.stroke);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0];
|
||||
};
|
||||
|
||||
export const BorderColorConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const setBorderColor = (color: string) => {
|
||||
app.style({ stroke: color }, getShapeIds(shapes));
|
||||
};
|
||||
|
||||
const iconColor = _getIconRenderColor(shapes);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<Palette
|
||||
colors={_colors}
|
||||
selected={iconColor}
|
||||
onSelect={setBorderColor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Border Color" placement="top-start">
|
||||
<IconButton>
|
||||
{iconColor === 'none' ? (
|
||||
<BorderColorNoneIcon />
|
||||
) : (
|
||||
<BorderColorDuotoneIcon style={{ color: iconColor }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { FC } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { TldrawApp, TLDR } from '@toeverything/components/board-state';
|
||||
import { Popover, styled, Divider } from '@toeverything/components/ui';
|
||||
import { getAnchor, useConfig } from './utils';
|
||||
import { BorderColorConfig } from './BorderColorConfig';
|
||||
import { FillColorConfig } from './FillColorConfig';
|
||||
import { FontSizeConfig } from './FontSizeConfig';
|
||||
import { StrokeLineStyleConfig } from './stroke-line-style-config';
|
||||
import { Group, UnGroup } from './GroupOperation';
|
||||
import { DeleteShapes } from './DeleteOperation';
|
||||
|
||||
export const CommandPanel: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const state = app.useStore();
|
||||
const bounds = TLDR.get_selected_bounds(state);
|
||||
const point = bounds
|
||||
? app.getScreenPoint([bounds.minX, bounds.minY])
|
||||
: undefined;
|
||||
|
||||
const anchor = getAnchor({
|
||||
x: point?.[0] || 0,
|
||||
y: (point?.[1] || 0) + 40,
|
||||
width: bounds?.width || 0,
|
||||
height: bounds?.height || 0,
|
||||
});
|
||||
|
||||
const config = useConfig(app);
|
||||
|
||||
const configNodes = {
|
||||
stroke: config.stroke.selectedShapes.length ? (
|
||||
<Fragment key="stroke">
|
||||
<StrokeLineStyleConfig
|
||||
app={app}
|
||||
shapes={config.stroke.selectedShapes}
|
||||
/>
|
||||
<BorderColorConfig
|
||||
app={app}
|
||||
shapes={config.stroke.selectedShapes}
|
||||
/>
|
||||
</Fragment>
|
||||
) : null,
|
||||
fill: config.fill.selectedShapes.length ? (
|
||||
<FillColorConfig
|
||||
key="fill"
|
||||
app={app}
|
||||
shapes={config.fill.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
font: config.font.selectedShapes.length ? (
|
||||
<FontSizeConfig
|
||||
key="font"
|
||||
app={app}
|
||||
shapes={config.font.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
group: config.group.selectedShapes.length ? (
|
||||
<Group key="group" app={app} shapes={config.group.selectedShapes} />
|
||||
) : null,
|
||||
ungroup: config.ungroup.selectedShapes.length ? (
|
||||
<UnGroup
|
||||
key="ungroup"
|
||||
app={app}
|
||||
shapes={config.ungroup.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
delete: (
|
||||
<DeleteShapes
|
||||
key="deleteShapes"
|
||||
app={app}
|
||||
shapes={config.deleteShapes.selectedShapes}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const nodes = Object.entries(configNodes).filter(([key, node]) => !!node);
|
||||
|
||||
return nodes.length ? (
|
||||
<Popover
|
||||
trigger="click"
|
||||
visible={!!point}
|
||||
anchor={anchor}
|
||||
popoverDirection="none"
|
||||
content={
|
||||
<PopoverContainer>
|
||||
{nodes.map(([key, node], idx, arr) => {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{node}
|
||||
{idx < arr.length - 1 ? (
|
||||
<div>
|
||||
<Divider orientation="vertical" />
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</PopoverContainer>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PopoverContainer = styled('div')({
|
||||
display: 'flex',
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { DeleteCashBinIcon } from '@toeverything/components/icons';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface DeleteShapesProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const DeleteShapes: FC<DeleteShapesProps> = ({ app, shapes }) => {
|
||||
const deleteShapes = () => {
|
||||
app.delete(getShapeIds(shapes));
|
||||
};
|
||||
return (
|
||||
<Tooltip content="Delete">
|
||||
<IconButton onClick={deleteShapes}>
|
||||
<DeleteCashBinIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import {
|
||||
Popover,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
useTheme,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
ShapeColorNoneIcon,
|
||||
ShapeColorDuotoneIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
import { Palette } from '../palette';
|
||||
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
type ColorType = 'none' | string;
|
||||
|
||||
const _colors: ColorType[] = [
|
||||
'none',
|
||||
'#F1675E',
|
||||
'#FF7F22',
|
||||
'#FFCB45',
|
||||
'#40DF9B',
|
||||
'#13D9E3',
|
||||
'#3E6FDB',
|
||||
'#7352F1',
|
||||
'#3A4C5C',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
const _getIconRenderColor = (shapes: TDShape[]) => {
|
||||
const counted = countBy(shapes, shape => shape.style.fill);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0];
|
||||
};
|
||||
|
||||
export const FillColorConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const setFillColor = (color: ColorType) => {
|
||||
app.style(
|
||||
{ fill: color, isFilled: color !== 'none' },
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
|
||||
const iconColor = _getIconRenderColor(shapes);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<Palette
|
||||
colors={_colors}
|
||||
selected={iconColor}
|
||||
onSelect={setFillColor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Fill Color" placement="top-start">
|
||||
<IconButton>
|
||||
{iconColor === 'none' ? (
|
||||
<ShapeColorNoneIcon />
|
||||
) : (
|
||||
<ShapeColorDuotoneIcon
|
||||
style={{
|
||||
color: iconColor,
|
||||
border:
|
||||
iconColor === '#FFFFFF'
|
||||
? `1px solid ${theme.affine.palette.tagHover}`
|
||||
: 0,
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { FontSizeStyle } from '@toeverything/components/board-types';
|
||||
import {
|
||||
Popover,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
TextFontIcon,
|
||||
HeadingOneIcon,
|
||||
HeadingTwoIcon,
|
||||
HeadingThreeIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface FontSizeConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
const _fontSizes = [
|
||||
{
|
||||
name: 'Heading 1',
|
||||
value: FontSizeStyle.h1,
|
||||
icon: <HeadingOneIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
value: FontSizeStyle.h2,
|
||||
icon: <HeadingTwoIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
value: FontSizeStyle.h3,
|
||||
icon: <HeadingThreeIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: FontSizeStyle.body,
|
||||
icon: <TextFontIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
const _getFontSize = (shapes: TDShape[]): FontSizeStyle => {
|
||||
const counted = countBy(shapes, shape => shape.style.fill);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0] as unknown as FontSizeStyle;
|
||||
};
|
||||
|
||||
export const FontSizeConfig: FC<FontSizeConfigProps> = ({ app, shapes }) => {
|
||||
const setFontSize = (size: FontSizeStyle) => {
|
||||
app.style({ fontSize: size }, getShapeIds(shapes));
|
||||
};
|
||||
|
||||
const fontSize = _getFontSize(shapes);
|
||||
const selected =
|
||||
_fontSizes.find(f => f.value === fontSize) || _fontSizes[3];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<div>
|
||||
{_fontSizes.map(fontSize => {
|
||||
return (
|
||||
<ListItemContainer
|
||||
key={fontSize.value}
|
||||
onClick={() => setFontSize(fontSize.value)}
|
||||
>
|
||||
{fontSize.icon}
|
||||
<ListItemTitle>{fontSize.name}</ListItemTitle>
|
||||
</ListItemContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Font Size" placement="top-start">
|
||||
<IconButton>{selected.icon}</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItemContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
color: theme.affine.palette.icons,
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'&:hover': {
|
||||
backgroundColor: theme.affine.palette.hover,
|
||||
},
|
||||
}));
|
||||
|
||||
const ListItemTitle = styled('span')(({ theme }) => ({
|
||||
marginLeft: '12px',
|
||||
color: theme.affine.palette.primaryText,
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { GroupIcon, UngroupIcon } from '@toeverything/components/icons';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface GroupAndUnGroupProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const Group: FC<GroupAndUnGroupProps> = ({ app, shapes }) => {
|
||||
const group = () => {
|
||||
app.group(getShapeIds(shapes));
|
||||
};
|
||||
return (
|
||||
<Tooltip content="Group">
|
||||
<IconButton onClick={group}>
|
||||
<GroupIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnGroup: FC<GroupAndUnGroupProps> = ({ app, shapes }) => {
|
||||
const ungroup = () => {
|
||||
app.ungroup(getShapeIds(shapes));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content="Ungroup">
|
||||
<IconButton onClick={ungroup}>
|
||||
<UngroupIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CommandPanel } from './CommandPanel';
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import type { FC } from 'react';
|
||||
import { DashStyle, StrokeWidth } from '@toeverything/components/board-types';
|
||||
import {
|
||||
LineNoneIcon,
|
||||
DashLineIcon,
|
||||
SolidLineIcon,
|
||||
BrushIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import {
|
||||
IconButton,
|
||||
styled,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
export const lineStyles = [
|
||||
{
|
||||
name: 'None',
|
||||
value: DashStyle.None,
|
||||
icon: <LineNoneIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Draw',
|
||||
value: DashStyle.Draw,
|
||||
icon: <BrushIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Solid',
|
||||
value: DashStyle.Solid,
|
||||
icon: <SolidLineIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Dash',
|
||||
value: DashStyle.Dashed,
|
||||
icon: <DashLineIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
interface LineStyleProps {
|
||||
strokeStyle: DashStyle;
|
||||
onStrokeStyleChange: (style: DashStyle) => void;
|
||||
|
||||
strokeWidth: StrokeWidth;
|
||||
onStrokeWidthChange: (width: StrokeWidth) => void;
|
||||
}
|
||||
|
||||
export const LineStyle: FC<LineStyleProps> = ({
|
||||
strokeStyle,
|
||||
onStrokeStyleChange,
|
||||
strokeWidth,
|
||||
onStrokeWidthChange,
|
||||
}) => {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Stroke Style</Title>
|
||||
<StrokeStyleContainer>
|
||||
{lineStyles.map(lineStyle => {
|
||||
const active = lineStyle.value === strokeStyle;
|
||||
return (
|
||||
<Tooltip key={lineStyle.value} content={lineStyle.name}>
|
||||
<IconButton
|
||||
className={active ? 'hover' : ''}
|
||||
onClick={() => {
|
||||
onStrokeStyleChange(lineStyle.value);
|
||||
}}
|
||||
>
|
||||
{lineStyle.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</StrokeStyleContainer>
|
||||
<Title>Thickness</Title>
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
value={strokeWidth}
|
||||
marks
|
||||
min={StrokeWidth.s1}
|
||||
max={StrokeWidth.s6}
|
||||
step={2}
|
||||
onChange={(event, value) => {
|
||||
onStrokeWidthChange(value as StrokeWidth);
|
||||
}}
|
||||
/>
|
||||
</SliderContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
width: '132px',
|
||||
margin: '0 10px',
|
||||
});
|
||||
|
||||
const Title = styled('div')(({ theme }) => ({
|
||||
fontSize: '12px',
|
||||
lineHeight: '14px',
|
||||
color: theme.affine.palette.menu,
|
||||
margin: '6px 0',
|
||||
}));
|
||||
|
||||
const StrokeStyleContainer = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const SliderContainer = styled('div')({
|
||||
padding: '0 6px',
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { DashStyle, StrokeWidth } from '@toeverything/components/board-types';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { Popover, IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { BrushIcon } from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from '../utils';
|
||||
import { LineStyle, lineStyles } from './LineStyle';
|
||||
|
||||
const _getStrokeStyle = (shapes: TDShape[]): DashStyle => {
|
||||
const counted = countBy(shapes, shape => shape.style.dash);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0] as DashStyle;
|
||||
};
|
||||
|
||||
const _getStrokeWidth = (shapes: TDShape[]): StrokeWidth => {
|
||||
const counted = countBy(shapes, shape => shape.style.strokeWidth);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return Number(max[0]) as StrokeWidth;
|
||||
};
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const StrokeLineStyleConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const strokeStyle = _getStrokeStyle(shapes);
|
||||
const strokeWidth = _getStrokeWidth(shapes);
|
||||
const setStrokeLineStyle = (style: DashStyle) => {
|
||||
app.style(
|
||||
{
|
||||
dash: style,
|
||||
},
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
const setStrokeLineWidth = (width: StrokeWidth) => {
|
||||
app.style(
|
||||
{
|
||||
strokeWidth: width,
|
||||
},
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
|
||||
const icon = lineStyles.find(style => style.value === strokeStyle)
|
||||
?.icon || <BrushIcon />;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<LineStyle
|
||||
strokeStyle={strokeStyle}
|
||||
onStrokeStyleChange={setStrokeLineStyle}
|
||||
strokeWidth={strokeWidth}
|
||||
onStrokeWidthChange={setStrokeLineWidth}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip placement="top-start" content="Stroke Style">
|
||||
<IconButton>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { StrokeLineStyleConfig } from './StrokeLineStyleConfig';
|
||||
@@ -0,0 +1,36 @@
|
||||
interface ShapeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getAnchor = (rect: ShapeRect) => {
|
||||
return {
|
||||
getBoundingClientRect(): DOMRect {
|
||||
const x = rect.x;
|
||||
const y = rect.y;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
const calcRect = {
|
||||
x,
|
||||
y,
|
||||
top: y,
|
||||
left: x,
|
||||
width,
|
||||
height,
|
||||
bottom: y + height,
|
||||
right: x + width,
|
||||
};
|
||||
const jsonStr = JSON.stringify(rect);
|
||||
|
||||
return {
|
||||
...calcRect,
|
||||
toJSON() {
|
||||
return jsonStr;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { getAnchor } from './get-anchor';
|
||||
export { useConfig, getShapeIds } from './use-config';
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { TDShapeType } from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
|
||||
interface Config {
|
||||
type: 'stroke' | 'fill' | 'font' | 'group' | 'ungroup' | 'deleteShapes';
|
||||
selectedShapes: TDShape[];
|
||||
}
|
||||
|
||||
const _createInitConfig = (): Record<Config['type'], Config> => {
|
||||
return {
|
||||
fill: {
|
||||
type: 'fill',
|
||||
selectedShapes: [],
|
||||
},
|
||||
stroke: {
|
||||
type: 'stroke',
|
||||
selectedShapes: [],
|
||||
},
|
||||
font: {
|
||||
type: 'font',
|
||||
selectedShapes: [],
|
||||
},
|
||||
group: {
|
||||
type: 'group',
|
||||
selectedShapes: [],
|
||||
},
|
||||
ungroup: {
|
||||
type: 'ungroup',
|
||||
selectedShapes: [],
|
||||
},
|
||||
deleteShapes: {
|
||||
type: 'deleteShapes',
|
||||
selectedShapes: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _isSupportStroke = (shape: TDShape): boolean => {
|
||||
return [
|
||||
TDShapeType.Rectangle,
|
||||
TDShapeType.Ellipse,
|
||||
TDShapeType.Hexagon,
|
||||
TDShapeType.Triangle,
|
||||
TDShapeType.WhiteArrow,
|
||||
TDShapeType.Pentagram,
|
||||
TDShapeType.Pencil,
|
||||
TDShapeType.Laser,
|
||||
TDShapeType.Highlight,
|
||||
TDShapeType.Arrow,
|
||||
TDShapeType.Line,
|
||||
].some(type => type === shape.type);
|
||||
};
|
||||
|
||||
const _isSupportFill = (shape: TDShape): boolean => {
|
||||
return [
|
||||
TDShapeType.Rectangle,
|
||||
TDShapeType.Ellipse,
|
||||
TDShapeType.Hexagon,
|
||||
TDShapeType.Triangle,
|
||||
TDShapeType.WhiteArrow,
|
||||
TDShapeType.Pentagram,
|
||||
].some(type => type === shape.type);
|
||||
};
|
||||
|
||||
export const useConfig = (app: TldrawApp): Record<Config['type'], Config> => {
|
||||
const state = app.useStore();
|
||||
const selectedShapes = TLDR.get_selected_shapes(state, app.currentPageId);
|
||||
const config = selectedShapes.reduce<Record<Config['type'], Config>>(
|
||||
(acc, cur) => {
|
||||
if (_isSupportStroke(cur)) {
|
||||
acc.stroke.selectedShapes.push(cur);
|
||||
}
|
||||
if (_isSupportFill(cur)) {
|
||||
acc.fill.selectedShapes.push(cur);
|
||||
acc.font.selectedShapes.push(cur);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
_createInitConfig()
|
||||
);
|
||||
|
||||
if (
|
||||
selectedShapes.length === 1 &&
|
||||
selectedShapes[0].type === TDShapeType.Group
|
||||
) {
|
||||
config.ungroup.selectedShapes = selectedShapes;
|
||||
}
|
||||
if (selectedShapes.length > 1) {
|
||||
config.group.selectedShapes = selectedShapes;
|
||||
}
|
||||
|
||||
config.deleteShapes.selectedShapes = selectedShapes;
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const getShapeIds = (shapes?: TDShape[]): string[] => {
|
||||
return (shapes || []).map(shape => shape.id).filter(id => !!id);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export const ContextMenu: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './context-menu';
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
import { useTldrawApp } from '../../hooks';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const refreshPage = () => {
|
||||
window.location.reload();
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
const copyError = () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = error.message;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
const downloadBackup = () => {
|
||||
app.saveProjectAs();
|
||||
};
|
||||
|
||||
const resetDocument = () => {
|
||||
app.resetDocument();
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<InnerContainer>
|
||||
<div>We've encountered an error!</div>
|
||||
<pre>
|
||||
<code>{error.message}</code>
|
||||
</pre>
|
||||
<div>
|
||||
<button onClick={copyError}>Copy Error</button>
|
||||
<button onClick={refreshPage}>Refresh Page</button>
|
||||
</div>
|
||||
<hr />
|
||||
<p>
|
||||
Keep getting this error?{' '}
|
||||
<a onClick={downloadBackup} title="Download your project">
|
||||
Download your project
|
||||
</a>{' '}
|
||||
as a backup and then{' '}
|
||||
<a onClick={resetDocument} title="Reset the document">
|
||||
reset the document
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '$canvas',
|
||||
});
|
||||
|
||||
const InnerContainer = styled('div')({
|
||||
backgroundColor: '$panel',
|
||||
border: '1px solid $panelContrast',
|
||||
padding: '$5',
|
||||
borderRadius: 8,
|
||||
boxShadow: '$panel',
|
||||
maxWidth: 320,
|
||||
color: '$text',
|
||||
fontFamily: '$ui',
|
||||
fontSize: '$2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '$3',
|
||||
'& > pre': {
|
||||
marginTop: '$3',
|
||||
marginBottom: '$3',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '$hover',
|
||||
padding: '$4',
|
||||
borderRadius: '$2',
|
||||
fontFamily: '"Menlo", "Monaco", monospace',
|
||||
fontWeight: 500,
|
||||
},
|
||||
'& p': {
|
||||
fontFamily: '$body',
|
||||
lineHeight: 1.7,
|
||||
padding: '$5',
|
||||
margin: 0,
|
||||
},
|
||||
'& a': {
|
||||
color: '$text',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
'& hr': {
|
||||
marginLeft: '-$5',
|
||||
marginRight: '-$5',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './error-fallback';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './loading';
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { useTldrawApp } from '../../hooks';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { TDSnapshot } from '@toeverything/components/board-types';
|
||||
|
||||
const loadingSelector = (s: TDSnapshot) => s.appState.isLoading;
|
||||
|
||||
export function Loading() {
|
||||
const app = useTldrawApp();
|
||||
const isLoading = app.useStore(loadingSelector);
|
||||
|
||||
return (
|
||||
<StyledLoadingPanelContainer hidden={!isLoading}>
|
||||
Loading...
|
||||
</StyledLoadingPanelContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledLoadingPanelContainer = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: `translate(-50%, 0)`,
|
||||
borderBottomLeftRadius: '12px',
|
||||
borderBottomRightRadius: '12px',
|
||||
padding: '8px 16px',
|
||||
fontFamily: 'var(--fonts-ui)',
|
||||
fontSize: 'var(--fontSizes-1)',
|
||||
boxShadow: 'var(--shadows-panel)',
|
||||
backgroundColor: 'white',
|
||||
zIndex: 200,
|
||||
pointerEvents: 'none',
|
||||
'& > div > *': {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
variants: {
|
||||
transform: {
|
||||
hidden: {
|
||||
transform: `translate(-50%, 100%)`,
|
||||
},
|
||||
visible: {
|
||||
transform: `translate(-50%, 0%)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user