init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
+18
View File
@@ -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": {}
}
]
}
+7
View File
@@ -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).
+9
View File
@@ -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',
};
+12
View File
@@ -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"
}
}
+44
View File
@@ -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
}
}
}
}
+221
View File
@@ -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>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { Login } from './login';
export { Error } from './error';
export { PageLoading } from './loading';
+23
View File
@@ -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 &nbsp; or &nbsp; 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 />
</>
);
}
+25
View File
@@ -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"
}
]
}
+22
View File
@@ -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"
]
}
+12
View File
@@ -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": {}
}
]
}
+7
View File
@@ -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"
}
+45
View File
@@ -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
}
}
}
}
+136
View File
@@ -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"
]
}
+12
View File
@@ -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"
]
}
+12
View File
@@ -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": {}
}
]
}
+7
View File
@@ -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"
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}
+18
View File
@@ -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": {}
}
]
}
+7
View File
@@ -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).
+10
View File
@@ -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',
};
+14
View File
@@ -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"
}
}
+45
View File
@@ -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
}
}
}
}
+550
View File
@@ -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';
@@ -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',
});
@@ -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>
);
};
@@ -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