├── .editorconfig ├── screencast.gif ├── .github ├── FUNDING.yml ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── .prettierrc.yml ├── docs ├── 04fc31dc7c0257d5b3f3.eot ├── 157fc7a37a71b9495da1.eot ├── 266ca63177bca6f330a7.png ├── 565ce5e4e7c8be823549.ttf ├── 8a525ab91769f6d60c94.ttf ├── 3843580eab4844b48210.woff ├── 8a26d7e1bb38c9c64a59.woff2 ├── 8b1c5e35bad17bae103e.woff2 ├── 9ad9cbe47f2f5821528d.woff ├── index.html └── main.js.LICENSE.txt ├── demo ├── GitHub-Mark-Light-32px.png ├── index.tsx ├── CloseAdditionalControlsButton.tsx ├── carbon.less ├── example.less └── ExampleApp.tsx ├── src ├── util │ ├── assertNever.ts │ ├── OptionalBlueprint.tsx │ ├── BoundingBox.ts │ ├── mosaicUtilities.ts │ └── mosaicUpdates.ts ├── buttons │ ├── Separator.tsx │ ├── defaultToolbarControls.tsx │ ├── SplitButton.tsx │ ├── ReplaceButton.tsx │ ├── RemoveButton.tsx │ ├── ExpandButton.tsx │ └── MosaicButton.tsx ├── internalTypes.ts ├── MosaicDropTarget.tsx ├── types.ts ├── RootDropTargets.tsx ├── MosaicZeroState.tsx ├── index.ts ├── MosaicRoot.tsx ├── contextTypes.ts ├── Split.tsx ├── Mosaic.tsx └── MosaicWindow.tsx ├── .npmignore ├── styles ├── mixins.less ├── index.less ├── mosaic.less ├── mosaic-window.less └── blueprint-theme.less ├── .gitignore ├── tsconfig-build.json ├── .mocharc.js ├── webpack ├── index-template.html ├── constants.ts ├── bundle.ts ├── hot.ts └── base.ts ├── .idea └── react-mosaic.iml ├── LICENSE ├── tsconfig.json ├── tslint.yml ├── test ├── boundingBoxSpec.ts ├── updatesSpec.ts └── utilitiesSpec.ts ├── .circleci └── config.yml ├── package.json └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{ts,tsx}] 2 | indent_size = 2 3 | quote_type = single 4 | -------------------------------------------------------------------------------- /screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/screencast.gif -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | - nomcopter 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | singleQuote: true 3 | trailingComma: all 4 | arrowParens: always 5 | printWidth: 120 6 | -------------------------------------------------------------------------------- /docs/04fc31dc7c0257d5b3f3.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/04fc31dc7c0257d5b3f3.eot -------------------------------------------------------------------------------- /docs/157fc7a37a71b9495da1.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/157fc7a37a71b9495da1.eot -------------------------------------------------------------------------------- /docs/266ca63177bca6f330a7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/266ca63177bca6f330a7.png -------------------------------------------------------------------------------- /docs/565ce5e4e7c8be823549.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/565ce5e4e7c8be823549.ttf -------------------------------------------------------------------------------- /docs/8a525ab91769f6d60c94.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/8a525ab91769f6d60c94.ttf -------------------------------------------------------------------------------- /demo/GitHub-Mark-Light-32px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/demo/GitHub-Mark-Light-32px.png -------------------------------------------------------------------------------- /docs/3843580eab4844b48210.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/3843580eab4844b48210.woff -------------------------------------------------------------------------------- /docs/8a26d7e1bb38c9c64a59.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/8a26d7e1bb38c9c64a59.woff2 -------------------------------------------------------------------------------- /docs/8b1c5e35bad17bae103e.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/8b1c5e35bad17bae103e.woff2 -------------------------------------------------------------------------------- /docs/9ad9cbe47f2f5821528d.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-react-mosaic/master/docs/9ad9cbe47f2f5821528d.woff -------------------------------------------------------------------------------- /src/util/assertNever.ts: -------------------------------------------------------------------------------- 1 | export function assertNever(shouldBeNever: never): never { 2 | throw new Error('Unhandled case: ' + JSON.stringify(shouldBeNever)); 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .editorconfig 3 | .gitignore 4 | .npmignore 5 | .circleci/ 6 | tsconfig*.json 7 | demo/ 8 | docs/ 9 | webpack/ 10 | screencast.gif 11 | yarn.lock 12 | -------------------------------------------------------------------------------- /styles/mixins.less: -------------------------------------------------------------------------------- 1 | .absolute-fill(@top: 0, @right: 0, @bottom: 0, @left: 0) { 2 | position: absolute; 3 | top: @top; 4 | right: @right; 5 | bottom: @bottom; 6 | left: @left; 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | /react-mosaic-component.css* 4 | .*.swp 5 | react-mosaic.tar.bz2 6 | npm-debug.log 7 | yarn-error.log 8 | 9 | **/.idea/* 10 | !**/.idea/*.iml 11 | -------------------------------------------------------------------------------- /src/buttons/Separator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Separator extends React.PureComponent { 4 | render() { 5 | return
; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | React Mosaic
-------------------------------------------------------------------------------- /tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": false, 4 | "outDir": "lib", 5 | "rootDir": "src" 6 | }, 7 | "exclude": [ 8 | "demo", 9 | "docs", 10 | "lib", 11 | "node_modules", 12 | "test", 13 | "webpack" 14 | ], 15 | "extends": "./tsconfig.json" 16 | } 17 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Fixes #0000 2 | 3 | #### Changes proposed in this pull request: 4 | 5 | 6 | 7 | #### Reviewers should focus on: 8 | 9 | 10 | 11 | #### Screenshot 12 | 13 | 14 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | require('jsdom-global/register'); 2 | require('ts-node').register({ 3 | transpileOnly: true, 4 | }); 5 | 6 | const mock = require('mock-require'); 7 | mock('rdndmb-html5-to-touch', {}); 8 | mock('react-dnd', {}); 9 | mock('react-dnd-multi-backend', {}); 10 | 11 | module.exports = { 12 | spec: 'test/*.ts', 13 | }; 14 | -------------------------------------------------------------------------------- /demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ExampleApp } from './ExampleApp'; 4 | 5 | const APP_ELEMENT = document.getElementById('app')!; 6 | const render = (Component: React.ComponentClass) => { 7 | ReactDOM.render(, APP_ELEMENT); 8 | }; 9 | 10 | render(ExampleApp); 11 | -------------------------------------------------------------------------------- /webpack/index-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Mosaic 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /webpack/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const DEMO_FOLDER = path.join(__dirname, '..', 'demo/'); 4 | export const CONSTANTS = { 5 | APP_ENTRY: path.join(DEMO_FOLDER, 'index.tsx'), 6 | HTML_TEMPLATE: path.join(__dirname, 'index-template.html'), 7 | DOCS_DIR: path.join(__dirname, '..', 'docs/'), 8 | DEV_SERVER_PORT: 8092, 9 | PUBLIC_PATH: '/public/', 10 | }; 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### Bug report 4 | 5 | - __Package version(s)__: 6 | - __Browser and OS versions__: 7 | 8 | #### Steps to reproduce 9 | 10 | 1. 11 | 1. 12 | 1. 13 | 14 | #### Actual behavior 15 | 16 | 17 | 18 | #### Expected behavior 19 | 20 | 21 | -------------------------------------------------------------------------------- /.idea/react-mosaic.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/internalTypes.ts: -------------------------------------------------------------------------------- 1 | import { MosaicPath } from './types'; 2 | 3 | export type MosaicDropTargetPosition = 'top' | 'bottom' | 'left' | 'right'; 4 | export const MosaicDropTargetPosition = { 5 | TOP: 'top' as 'top', 6 | BOTTOM: 'bottom' as 'bottom', 7 | LEFT: 'left' as 'left', 8 | RIGHT: 'right' as 'right', 9 | }; 10 | 11 | export interface MosaicDropData { 12 | path?: MosaicPath; 13 | position?: MosaicDropTargetPosition; 14 | } 15 | 16 | export interface MosaicDragItem { 17 | mosaicId: string; 18 | hideTimer: number; 19 | } 20 | -------------------------------------------------------------------------------- /src/buttons/defaultToolbarControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExpandButton } from './ExpandButton'; 3 | import { RemoveButton } from './RemoveButton'; 4 | import { ReplaceButton } from './ReplaceButton'; 5 | import { SplitButton } from './SplitButton'; 6 | 7 | export const DEFAULT_CONTROLS_WITH_CREATION = React.Children.toArray([ 8 | , 9 | , 10 | , 11 | , 12 | ]); 13 | export const DEFAULT_CONTROLS_WITHOUT_CREATION = React.Children.toArray([, ]); 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Kevin Verdieck, originally developed at Palantir Technologies, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /webpack/bundle.ts: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import config from './base'; 3 | 4 | const bundleConfig: webpack.Configuration = { 5 | ...config, 6 | mode: 'production', 7 | optimization: { 8 | ...config.optimization, 9 | minimize: true, 10 | }, 11 | plugins: [ 12 | ...(config.plugins || []), 13 | new webpack.DefinePlugin({ 14 | // This is a macro substitution; it has to end up in the source with quotes. 15 | 'process.env.NODE_ENV': '"production"', 16 | }), 17 | new webpack.LoaderOptionsPlugin({ 18 | minimize: true, 19 | }), 20 | ], 21 | }; 22 | 23 | // tslint:disable-next-line no-default-export 24 | export default bundleConfig; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "declaration": true, 5 | "esModuleInterop": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "lib": ["dom", "es5", "scripthost", "es2015.promise"], 10 | "moduleResolution": "node", 11 | "module": "commonjs", 12 | "noEmit": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "target": "es5", 18 | "skipLibCheck": true, 19 | "sourceMap": true, 20 | "strict": true 21 | }, 22 | "exclude": ["docs", "lib", "node_modules"], 23 | } 24 | -------------------------------------------------------------------------------- /demo/CloseAdditionalControlsButton.tsx: -------------------------------------------------------------------------------- 1 | import { Classes } from '@blueprintjs/core'; 2 | import classNames from 'classnames'; 3 | import React from 'react'; 4 | 5 | import { MosaicWindowContext } from '../src'; 6 | 7 | export class CloseAdditionalControlsButton extends React.PureComponent { 8 | static contextType = MosaicWindowContext; 9 | context!: MosaicWindowContext; 10 | 11 | render() { 12 | return ( 13 |
14 | 20 |
21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /styles/index.less: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Kevin Verdieck, originally developed at Palantir Technologies, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | @import 'mosaic'; 18 | @import 'mosaic-window'; 19 | @import 'blueprint-theme'; 20 | -------------------------------------------------------------------------------- /webpack/hot.ts: -------------------------------------------------------------------------------- 1 | import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin'; 2 | import webpack from 'webpack'; 3 | import 'webpack-dev-server'; 4 | import config from './base'; 5 | import { CONSTANTS } from './constants'; 6 | 7 | const hotConfig: webpack.Configuration = { 8 | ...config, 9 | mode: 'development', 10 | devtool: 'cheap-module-source-map', 11 | stats: 'minimal', 12 | optimization: { 13 | runtimeChunk: 'single', 14 | }, 15 | devServer: { 16 | static: CONSTANTS.DOCS_DIR, 17 | historyApiFallback: true, 18 | hot: true, 19 | host: '0.0.0.0', 20 | port: CONSTANTS.DEV_SERVER_PORT, 21 | }, 22 | plugins: [...(config.plugins || []), new ReactRefreshWebpackPlugin()], 23 | }; 24 | 25 | // tslint:disable-next-line no-default-export 26 | export default hotConfig; 27 | -------------------------------------------------------------------------------- /tslint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | defaultSeverity: error 3 | extends: 4 | - tslint:recommended 5 | - tslint-react 6 | - tslint-plugin-prettier 7 | - tslint-config-prettier 8 | linterOptions: 9 | format: stylish 10 | rules: 11 | curly: true 12 | interface-name: 13 | options: 14 | - never-prefix 15 | member-access: false 16 | member-ordering: false 17 | object-literal-sort-keys: false 18 | ordered-imports: 19 | options: 20 | import-sources-order: case-insensitive 21 | module-source-path: full 22 | named-imports-order: case-insensitive 23 | import-blacklist: [true, 'lodash'] 24 | prettier: true 25 | no-namespace: false 26 | variable-name: 27 | options: 28 | - check-format 29 | - allow-leading-underscore 30 | - allow-pascal-case 31 | - ban-keywords 32 | array-type: 33 | options: 34 | - array 35 | no-console: 36 | options: 37 | - log 38 | switch-default: true 39 | max-classes-per-file: false 40 | await-promise: true 41 | ban-comma-operator: true 42 | prefer-object-spread: true 43 | no-default-export: true 44 | jsx-no-lambda: false 45 | jsx-key: false 46 | -------------------------------------------------------------------------------- /src/buttons/SplitButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import noop from 'lodash/noop'; 3 | import React from 'react'; 4 | 5 | import { MosaicWindowContext } from '../contextTypes'; 6 | import { OptionalBlueprint } from '../util/OptionalBlueprint'; 7 | import { DefaultToolbarButton, MosaicButtonProps } from './MosaicButton'; 8 | 9 | export class SplitButton extends React.PureComponent { 10 | static contextType = MosaicWindowContext; 11 | context!: MosaicWindowContext; 12 | 13 | render() { 14 | return ( 15 | 23 | ); 24 | } 25 | 26 | private split = () => { 27 | this.context.mosaicWindowActions 28 | .split() 29 | .then(() => { 30 | if (this.props.onClick) { 31 | this.props.onClick(); 32 | } 33 | }) 34 | .catch(noop); // Swallow rejections (i.e. on user cancel) 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/buttons/ReplaceButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import noop from 'lodash/noop'; 3 | import React from 'react'; 4 | 5 | import { MosaicWindowContext } from '../contextTypes'; 6 | import { OptionalBlueprint } from '../util/OptionalBlueprint'; 7 | import { DefaultToolbarButton, MosaicButtonProps } from './MosaicButton'; 8 | 9 | export class ReplaceButton extends React.PureComponent { 10 | static contextType = MosaicWindowContext; 11 | context!: MosaicWindowContext; 12 | 13 | render() { 14 | return ( 15 | 23 | ); 24 | } 25 | 26 | private replace = () => { 27 | this.context.mosaicWindowActions 28 | .replaceWithNew() 29 | .then(() => { 30 | if (this.props.onClick) { 31 | this.props.onClick(); 32 | } 33 | }) 34 | .catch(noop); // Swallow rejections (i.e. on user cancel) 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/buttons/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import { MosaicContext, MosaicRootActions, MosaicWindowContext } from '../contextTypes'; 5 | import { OptionalBlueprint } from '../util/OptionalBlueprint'; 6 | import { DefaultToolbarButton, MosaicButtonProps } from './MosaicButton'; 7 | 8 | export class RemoveButton extends React.PureComponent { 9 | static contextType = MosaicWindowContext; 10 | context!: MosaicWindowContext; 11 | 12 | render() { 13 | return ( 14 | 15 | {({ mosaicActions, blueprintNamespace }) => ( 16 | 21 | )} 22 | 23 | ); 24 | } 25 | 26 | private createRemove(mosaicActions: MosaicRootActions) { 27 | return () => { 28 | mosaicActions.remove(this.context.mosaicWindowActions.getPath()); 29 | 30 | if (this.props.onClick) { 31 | this.props.onClick(); 32 | } 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/buttons/ExpandButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | 4 | import { MosaicContext, MosaicRootActions, MosaicWindowContext } from '../contextTypes'; 5 | import { OptionalBlueprint } from '../util/OptionalBlueprint'; 6 | import { DefaultToolbarButton, MosaicButtonProps } from './MosaicButton'; 7 | 8 | export class ExpandButton extends React.PureComponent { 9 | static contextType = MosaicWindowContext; 10 | context!: MosaicWindowContext; 11 | 12 | render() { 13 | return ( 14 | 15 | {({ mosaicActions }) => ( 16 | 24 | )} 25 | 26 | ); 27 | } 28 | 29 | private createExpand(mosaicActions: MosaicRootActions) { 30 | return () => { 31 | mosaicActions.expand(this.context.mosaicWindowActions.getPath()); 32 | 33 | if (this.props.onClick) { 34 | this.props.onClick(); 35 | } 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/buttons/MosaicButton.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React from 'react'; 3 | import { MosaicContext } from '../contextTypes'; 4 | 5 | import { OptionalBlueprint } from '../util/OptionalBlueprint'; 6 | 7 | export const DefaultToolbarButton = ({ 8 | title, 9 | className, 10 | onClick, 11 | text, 12 | }: { 13 | title: string; 14 | className: string; 15 | onClick: (event: React.MouseEvent) => any; 16 | text?: string; 17 | }) => { 18 | const { blueprintNamespace } = React.useContext(MosaicContext); 19 | return ( 20 | 31 | ); 32 | }; 33 | 34 | /** 35 | * @deprecated: see @DefaultToolbarButton 36 | */ 37 | export const createDefaultToolbarButton = ( 38 | title: string, 39 | className: string, 40 | onClick: (event: React.MouseEvent) => any, 41 | text?: string, 42 | ) => ; 43 | 44 | export interface MosaicButtonProps { 45 | onClick?: () => void; 46 | } 47 | -------------------------------------------------------------------------------- /src/MosaicDropTarget.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { useContext } from 'react'; 3 | import { useDrop } from 'react-dnd'; 4 | 5 | import { MosaicContext } from './contextTypes'; 6 | import { MosaicDragItem, MosaicDropData, MosaicDropTargetPosition } from './internalTypes'; 7 | import { MosaicDragType, MosaicPath } from './types'; 8 | 9 | export interface MosaicDropTargetProps { 10 | position: MosaicDropTargetPosition; 11 | path: MosaicPath; 12 | } 13 | 14 | export function MosaicDropTarget({ path, position }: MosaicDropTargetProps) { 15 | const { mosaicId } = useContext(MosaicContext); 16 | const [{ isOver, draggedMosaicId }, connectDropTarget] = useDrop({ 17 | accept: MosaicDragType.WINDOW, 18 | drop: (item: MosaicDragItem | undefined, _monitor): MosaicDropData => { 19 | if (mosaicId === item?.mosaicId) { 20 | return { path, position }; 21 | } else { 22 | return {}; 23 | } 24 | }, 25 | collect: (monitor) => ({ 26 | isOver: monitor.isOver(), 27 | draggedMosaicId: (monitor.getItem() || {}).mosaicId, 28 | }), 29 | }); 30 | return ( 31 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/util/OptionalBlueprint.tsx: -------------------------------------------------------------------------------- 1 | import type { Classes } from '@blueprintjs/core'; 2 | import type { IconNames } from '@blueprintjs/icons'; 3 | import classNames from 'classnames'; 4 | import kebabCase from 'lodash/kebabCase'; 5 | import * as React from 'react'; 6 | import { MosaicContext } from '../contextTypes'; 7 | 8 | export namespace OptionalBlueprint { 9 | export const Icon = ({ 10 | icon, 11 | className, 12 | size = 'standard', 13 | }: { 14 | icon: keyof typeof IconNames; 15 | className?: string; 16 | size?: 'standard' | 'large'; 17 | }) => { 18 | const { blueprintNamespace } = React.useContext(MosaicContext); 19 | return ( 20 | 23 | ); 24 | }; 25 | 26 | type BlueprintClass = { 27 | [K in keyof typeof Classes]: (typeof Classes)[K] extends string ? K : never; 28 | }[keyof typeof Classes]; 29 | 30 | export function getClasses(blueprintNamespace: string, ...names: BlueprintClass[]): string { 31 | return names.map((name) => `${blueprintNamespace}-${kebabCase(name)}`).join(' '); 32 | } 33 | 34 | export function getIconClass(blueprintNamespace: string, iconName: keyof typeof IconNames): string { 35 | return `${blueprintNamespace}-icon-${kebabCase(iconName)}`; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /demo/carbon.less: -------------------------------------------------------------------------------- 1 | #carbonads * { 2 | margin: initial; 3 | padding: initial; 4 | } 5 | #carbonads { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', 7 | Helvetica, Arial, sans-serif; 8 | } 9 | #carbonads { 10 | display: flex; 11 | max-width: 330px; 12 | background-color: hsl(0, 0%, 98%); 13 | box-shadow: 0 1px 4px 1px hsla(0, 0%, 0%, 0.1); 14 | z-index: 100; 15 | } 16 | #carbonads a { 17 | color: inherit; 18 | text-decoration: none; 19 | } 20 | #carbonads a:hover { 21 | color: inherit; 22 | } 23 | #carbonads span { 24 | position: relative; 25 | display: block; 26 | overflow: hidden; 27 | } 28 | #carbonads .carbon-wrap { 29 | display: flex; 30 | } 31 | #carbonads .carbon-img { 32 | display: block; 33 | margin: 0; 34 | line-height: 1; 35 | } 36 | #carbonads .carbon-img img { 37 | display: block; 38 | } 39 | #carbonads .carbon-text { 40 | font-size: 13px; 41 | padding: 10px; 42 | margin-bottom: 16px; 43 | line-height: 1.5; 44 | text-align: left; 45 | } 46 | #carbonads .carbon-poweredby { 47 | display: block; 48 | padding: 6px 8px; 49 | background: #f1f1f2; 50 | text-align: center; 51 | text-transform: uppercase; 52 | letter-spacing: 0.5px; 53 | font-weight: 600; 54 | font-size: 8px; 55 | line-height: 1; 56 | border-top-left-radius: 3px; 57 | position: absolute; 58 | bottom: 0; 59 | right: 0; 60 | } 61 | -------------------------------------------------------------------------------- /demo/example.less: -------------------------------------------------------------------------------- 1 | @import (reference) '~@blueprintjs/core/lib/less/variables'; 2 | 3 | html, 4 | body, 5 | #app, 6 | .react-mosaic-example-app { 7 | margin: 0; 8 | height: 100%; 9 | width: 100%; 10 | overflow: hidden; 11 | } 12 | 13 | .react-mosaic-example-app { 14 | .actions-label { 15 | margin-right: 10px; 16 | } 17 | 18 | .theme-selection { 19 | margin: 0; 20 | } 21 | 22 | .navbar-separator { 23 | height: 20px; 24 | border-left: 1px solid @gray1; 25 | margin: 10px; 26 | } 27 | 28 | .@{ns}-navbar { 29 | overflow: hidden; 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | } 34 | 35 | .@{ns}-navbar-heading a { 36 | color: @white; 37 | 38 | .version { 39 | color: @gray2; 40 | } 41 | } 42 | 43 | > .mosaic { 44 | height: ~'calc(100% - 50px)'; 45 | } 46 | 47 | .github-link { 48 | @size: 32px; 49 | height: @size; 50 | width: @size; 51 | margin-left: 15px; 52 | } 53 | 54 | .toolbar-example { 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | width: 100%; 59 | height: 100%; 60 | } 61 | } 62 | 63 | body { 64 | user-select: none; 65 | -webkit-user-select: none; 66 | } 67 | 68 | .example-window { 69 | height: 100%; 70 | padding: 20px; 71 | display: flex; 72 | align-items: center; 73 | flex-direction: column; 74 | 75 | .ad-container { 76 | margin-top: 20px; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Spec } from 'immutability-helper'; 2 | 3 | /** 4 | * Valid node types 5 | * @see React.Key 6 | */ 7 | export type MosaicKey = string | number; 8 | 9 | /** 10 | * Base type for the Mosaic binary tree 11 | */ 12 | export type MosaicNode = MosaicParent | T; 13 | 14 | /** 15 | * Row means each window is side-by-side 16 | */ 17 | export type MosaicDirection = 'row' | 'column'; 18 | 19 | export interface MosaicParent { 20 | direction: MosaicDirection; 21 | first: MosaicNode; 22 | second: MosaicNode; 23 | splitPercentage?: number; 24 | } 25 | 26 | export type MosaicBranch = 'first' | 'second'; 27 | export type MosaicPath = MosaicBranch[]; 28 | 29 | /** 30 | * Used by many utility methods to update the tree. 31 | * spec will be passed to https://github.com/kolodny/immutability-helper 32 | */ 33 | export type MosaicUpdateSpec = Spec>; 34 | 35 | export interface MosaicUpdate { 36 | path: MosaicPath; 37 | spec: MosaicUpdateSpec; 38 | } 39 | 40 | /** 41 | * Mosaic needs a way to resolve `MosaicKey` into react elements for display. 42 | * This provides a way to render them. 43 | */ 44 | export type TileRenderer = (t: T, path: MosaicBranch[]) => JSX.Element; 45 | 46 | /** 47 | * Function that provides a new node to put into the tree 48 | */ 49 | export type CreateNode = (...args: any[]) => Promise> | MosaicNode; 50 | 51 | /** 52 | * Used by `react-dnd` 53 | * @type {{WINDOW: string}} 54 | */ 55 | export const MosaicDragType = { 56 | WINDOW: 'MosaicWindow', 57 | }; 58 | 59 | export interface EnabledResizeOptions { 60 | minimumPaneSizePercentage?: number; // Default: 20 61 | } 62 | 63 | export type ResizeOptions = 'DISABLED' | EnabledResizeOptions; 64 | -------------------------------------------------------------------------------- /src/RootDropTargets.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import values from 'lodash/values'; 3 | import React from 'react'; 4 | import { useDrop } from 'react-dnd'; 5 | 6 | import { MosaicDropTargetPosition } from './internalTypes'; 7 | import { MosaicDropTarget } from './MosaicDropTarget'; 8 | import { MosaicDragType } from './types'; 9 | 10 | export const RootDropTargets = React.memo(() => { 11 | const [{ isDragging }] = useDrop({ 12 | accept: MosaicDragType.WINDOW, 13 | collect: (monitor) => ({ 14 | isDragging: monitor.getItem() !== null && monitor.getItemType() === MosaicDragType.WINDOW, 15 | }), 16 | }); 17 | const delayedIsDragging = useDelayedTrue(isDragging, 0); 18 | return ( 19 |
24 | {values(MosaicDropTargetPosition).map((position) => ( 25 | 26 | ))} 27 |
28 | ); 29 | }); 30 | RootDropTargets.displayName = 'RootDropTargets'; 31 | 32 | function useDelayedTrue(currentValue: boolean, delay: number): boolean { 33 | const delayedRef = React.useRef(currentValue); 34 | 35 | const [, setCounter] = React.useState(0); 36 | const setAndRender = (newValue: boolean) => { 37 | delayedRef.current = newValue; 38 | setCounter((count) => count + 1); 39 | }; 40 | 41 | if (!currentValue) { 42 | delayedRef.current = false; 43 | } 44 | 45 | React.useEffect(() => { 46 | if (delayedRef.current === currentValue || !currentValue) { 47 | return; 48 | } 49 | 50 | const timer = window.setTimeout(() => setAndRender(true), delay); 51 | return () => { 52 | window.clearTimeout(timer); 53 | }; 54 | }, [currentValue]); 55 | 56 | return delayedRef.current; 57 | } 58 | -------------------------------------------------------------------------------- /src/MosaicZeroState.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import noop from 'lodash/noop'; 3 | import React from 'react'; 4 | 5 | import { MosaicContext } from './contextTypes'; 6 | import { CreateNode, MosaicKey } from './types'; 7 | import { OptionalBlueprint } from './util/OptionalBlueprint'; 8 | 9 | export interface MosaicZeroStateProps { 10 | createNode?: CreateNode; 11 | } 12 | 13 | export class MosaicZeroState extends React.PureComponent> { 14 | static contextType = MosaicContext; 15 | context!: MosaicContext; 16 | 17 | render() { 18 | return ( 19 |
25 |
26 | 27 |
28 |

No Windows Present

29 |
30 | {this.props.createNode && ( 31 | 40 | )} 41 |
42 |
43 | ); 44 | } 45 | 46 | private replace = () => 47 | Promise.resolve(this.props.createNode!()) 48 | .then((node) => this.context.mosaicActions.replaceWith([], node)) 49 | .catch(noop); // Swallow rejections (i.e. on user cancel) 50 | } 51 | -------------------------------------------------------------------------------- /webpack/base.ts: -------------------------------------------------------------------------------- 1 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 2 | import webpack from 'webpack'; 3 | import { CONSTANTS } from './constants'; 4 | 5 | const config: webpack.Configuration = { 6 | entry: CONSTANTS.APP_ENTRY, 7 | output: { 8 | filename: '[name].js', 9 | path: CONSTANTS.DOCS_DIR, 10 | }, 11 | devtool: 'source-map', 12 | resolve: { 13 | extensions: ['.webpack.js', '.web.js', '.json', '.ts', '.js', '.tsx'], 14 | }, 15 | optimization: { 16 | moduleIds: 'named', 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.html$/, 22 | loader: 'html-loader', 23 | }, 24 | { 25 | test: /\.tsx?$/, 26 | use: [ 27 | { 28 | loader: 'ts-loader', 29 | options: { 30 | compilerOptions: { 31 | noEmit: false, 32 | declaration: false, 33 | }, 34 | }, 35 | }, 36 | ], 37 | }, 38 | { 39 | test: /node_modules.*\.js$/, 40 | loader: 'source-map-loader', 41 | }, 42 | { 43 | test: /\.css$/, 44 | use: [ 45 | { 46 | loader: 'style-loader', 47 | }, 48 | { 49 | loader: 'css-loader', 50 | }, 51 | ], 52 | }, 53 | { 54 | test: /\.jpe?g$|\.gif$|\.png$|\.svg$|\.woff2?$|\.ttf$|\.eot$/, 55 | type: 'asset/resource', 56 | }, 57 | { 58 | test: /\.less/, 59 | use: [ 60 | { 61 | loader: 'style-loader', 62 | }, 63 | { 64 | loader: 'css-loader', 65 | }, 66 | { 67 | loader: 'less-loader', 68 | }, 69 | ], 70 | }, 71 | ], 72 | }, 73 | plugins: [ 74 | new HtmlWebpackPlugin({ 75 | template: CONSTANTS.HTML_TEMPLATE, 76 | }), 77 | ], 78 | }; 79 | 80 | // tslint:disable-next-line no-default-export 81 | export default config; 82 | -------------------------------------------------------------------------------- /test/boundingBoxSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { BoundingBox } from '../src/util/BoundingBox'; 3 | 4 | // Yay javascript float precision 5 | function expectBoundingBoxCloseTo(a: BoundingBox, b: BoundingBox, delta: number = 0.000001) { 6 | expect(a.top).to.be.closeTo(b.top, delta); 7 | expect(a.right).to.be.closeTo(b.right, delta); 8 | expect(a.bottom).to.be.closeTo(b.bottom, delta); 9 | expect(a.left).to.be.closeTo(b.left, delta); 10 | } 11 | 12 | describe('BoundingBox', () => { 13 | describe('Root', () => { 14 | const EMPTY = BoundingBox.empty(); 15 | it('should split column', () => { 16 | const { first, second } = BoundingBox.split(EMPTY, 25, 'column'); 17 | expectBoundingBoxCloseTo(first, { 18 | top: 0, 19 | right: 0, 20 | bottom: 75, 21 | left: 0, 22 | }); 23 | expectBoundingBoxCloseTo(second, { 24 | top: 25, 25 | right: 0, 26 | bottom: 0, 27 | left: 0, 28 | }); 29 | }); 30 | it('should split row', () => { 31 | const { first, second } = BoundingBox.split(EMPTY, 25, 'row'); 32 | expectBoundingBoxCloseTo(first, { 33 | top: 0, 34 | right: 75, 35 | bottom: 0, 36 | left: 0, 37 | }); 38 | expectBoundingBoxCloseTo(second, { 39 | top: 0, 40 | right: 0, 41 | bottom: 0, 42 | left: 25, 43 | }); 44 | }); 45 | }); 46 | describe('Complex', () => { 47 | const COMPLEX = { 48 | top: 100 / 6, 49 | right: 100 / 6, 50 | bottom: 100 / 6, 51 | left: 100 / 6, 52 | }; 53 | it('should split column', () => { 54 | const { first, second } = BoundingBox.split(COMPLEX, 25, 'column'); 55 | expectBoundingBoxCloseTo(first, { 56 | top: 100 / 6, 57 | right: 100 / 6, 58 | bottom: (100 / 6) * 4, 59 | left: 100 / 6, 60 | }); 61 | expectBoundingBoxCloseTo(second, { 62 | top: (100 / 6) * 2, 63 | right: 100 / 6, 64 | bottom: 100 / 6, 65 | left: 100 / 6, 66 | }); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2019 Kevin Verdieck, originally developed at Palantir Technologies, Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | export { 18 | Mosaic, 19 | MosaicProps, 20 | MosaicUncontrolledProps, 21 | MosaicControlledProps, 22 | MosaicWithoutDragDropContext, 23 | } from './Mosaic'; 24 | export { 25 | MosaicNode, 26 | MosaicDragType, 27 | MosaicDirection, 28 | MosaicBranch, 29 | CreateNode, 30 | MosaicParent, 31 | MosaicPath, 32 | MosaicUpdate, 33 | MosaicUpdateSpec, 34 | TileRenderer, 35 | } from './types'; 36 | export { MosaicContext, MosaicRootActions, MosaicWindowActions, MosaicWindowContext } from './contextTypes'; 37 | export { 38 | buildSpecFromUpdate, 39 | createDragToUpdates, 40 | createExpandUpdate, 41 | createHideUpdate, 42 | createRemoveUpdate, 43 | updateTree, 44 | } from './util/mosaicUpdates'; 45 | export { 46 | createBalancedTreeFromLeaves, 47 | Corner, 48 | getAndAssertNodeAtPathExists, 49 | getLeaves, 50 | getNodeAtPath, 51 | getOtherBranch, 52 | getOtherDirection, 53 | getPathToCorner, 54 | isParent, 55 | } from './util/mosaicUtilities'; 56 | export { MosaicWindow, MosaicWindowProps } from './MosaicWindow'; 57 | export { createDefaultToolbarButton, DefaultToolbarButton, MosaicButtonProps } from './buttons/MosaicButton'; 58 | export { MosaicZeroState, MosaicZeroStateProps } from './MosaicZeroState'; 59 | export { Separator } from './buttons/Separator'; 60 | export { ExpandButton } from './buttons/ExpandButton'; 61 | export { ReplaceButton } from './buttons/ReplaceButton'; 62 | export { SplitButton } from './buttons/SplitButton'; 63 | export { RemoveButton } from './buttons/RemoveButton'; 64 | export { DEFAULT_CONTROLS_WITH_CREATION, DEFAULT_CONTROLS_WITHOUT_CREATION } from './buttons/defaultToolbarControls'; 65 | -------------------------------------------------------------------------------- /styles/mosaic.less: -------------------------------------------------------------------------------- 1 | @import (reference) './mixins'; 2 | 3 | @split-size: 6px; 4 | 5 | .mosaic { 6 | height: 100%; 7 | width: 100%; 8 | 9 | &, 10 | > * { 11 | box-sizing: border-box; 12 | } 13 | 14 | .mosaic-zero-state { 15 | @padding: @split-size; 16 | .absolute-fill(@padding, @padding, @padding, @padding); 17 | width: auto; 18 | height: auto; 19 | z-index: 1; 20 | } 21 | } 22 | 23 | .mosaic-root { 24 | @size: @split-size / 2; 25 | .absolute-fill(@size, @size, @size, @size); 26 | } 27 | 28 | .mosaic-split { 29 | position: absolute; 30 | z-index: 1; 31 | touch-action: none; 32 | 33 | &:hover { 34 | background: black; 35 | } 36 | 37 | .mosaic-split-line { 38 | position: absolute; 39 | } 40 | 41 | &.-row { 42 | margin-left: -@split-size / 2; 43 | width: @split-size; 44 | cursor: ew-resize; 45 | 46 | .mosaic-split-line { 47 | top: 0; 48 | bottom: 0; 49 | left: @split-size / 2; 50 | right: @split-size / 2; 51 | } 52 | } 53 | 54 | &.-column { 55 | margin-top: -@split-size / 2; 56 | height: @split-size; 57 | cursor: ns-resize; 58 | 59 | .mosaic-split-line { 60 | top: @split-size / 2; 61 | bottom: @split-size / 2; 62 | left: 0; 63 | right: 0; 64 | } 65 | } 66 | } 67 | 68 | .mosaic-tile { 69 | position: absolute; 70 | margin: @split-size / 2; 71 | 72 | > * { 73 | height: 100%; 74 | width: 100%; 75 | } 76 | } 77 | 78 | .split-percentages(@split-amount) { 79 | @amount: ~'calc(100% - ' @split-amount ~')'; 80 | &.left { 81 | right: @amount; 82 | } 83 | &.right { 84 | left: @amount; 85 | } 86 | &.bottom { 87 | top: @amount; 88 | } 89 | &.top { 90 | bottom: @amount; 91 | } 92 | } 93 | 94 | .mosaic-drop-target { 95 | position: relative; 96 | 97 | &.drop-target-hover .drop-target-container { 98 | display: block; 99 | } 100 | 101 | &.mosaic > .drop-target-container .drop-target { 102 | .split-percentages(10px); 103 | } 104 | 105 | .drop-target-container { 106 | .absolute-fill(); 107 | display: none; 108 | 109 | &.-dragging { 110 | display: block; 111 | } 112 | 113 | .drop-target { 114 | .absolute-fill(); 115 | .split-percentages(30%); 116 | background: fade(black, 20%); 117 | border: 2px solid black; 118 | opacity: 0; 119 | z-index: 5; 120 | 121 | &.drop-target-hover { 122 | opacity: 1; 123 | .split-percentages(50%); 124 | } 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | references: 4 | js_deps_cache_key: &js_deps_cache_key v1-dependencies-{{ checksum "yarn.lock" }} 5 | workspace_root: &workspace_root . 6 | attach_workspace: &attach_workspace 7 | attach_workspace: 8 | at: *workspace_root 9 | jobs: 10 | build: 11 | docker: 12 | - image: circleci/node:gallium 13 | working_directory: ~/react-mosaic 14 | steps: 15 | - checkout 16 | - run: 17 | name: Install Yarn 18 | command: | 19 | curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version 1.22.18 20 | export PATH="$HOME/.yarn/bin:$PATH" 21 | - restore_cache: 22 | keys: 23 | - *js_deps_cache_key 24 | - run: yarn install --frozen-lockfile 25 | - save_cache: 26 | paths: 27 | - node_modules 28 | - ~/.cache/yarn 29 | key: *js_deps_cache_key 30 | - run: 31 | name: Build 32 | command: yarn build 33 | - persist_to_workspace: 34 | root: *workspace_root 35 | paths: 36 | - . 37 | test: 38 | docker: 39 | - image: circleci/node:dubnium 40 | working_directory: ~/react-mosaic 41 | steps: 42 | - *attach_workspace 43 | - run: mkdir reports 44 | - run: 45 | name: Run unit tests 46 | command: yarn test:unit --reporter mocha-junit-reporter --reporter-options mochaFile=reports/mocha/test-results.xml 47 | - run: 48 | name: Lint 49 | command: yarn test:lint --format junit -o ./reports/tslint/tslint.xml 50 | - store_test_results: 51 | path: reports 52 | - store_artifacts: 53 | path: ./reports/mocha/test-results.xml 54 | - store_artifacts: 55 | path: ./reports/tslint/tslint.xml 56 | deploy: 57 | docker: 58 | - image: circleci/node:dubnium 59 | working_directory: ~/react-mosaic 60 | steps: 61 | - *attach_workspace 62 | - run: 63 | name: Publish 64 | command: | 65 | npm set //registry.npmjs.org/:_authToken=$NPM_TOKEN 66 | npm publish 67 | workflows: 68 | version: 2 69 | build-test-deploy: 70 | jobs: 71 | - build: 72 | filters: 73 | tags: 74 | only: /^v.*/ 75 | - test: 76 | requires: 77 | - build 78 | filters: 79 | tags: 80 | only: /^v.*/ 81 | - deploy: 82 | requires: 83 | - test 84 | filters: 85 | tags: 86 | only: /^v.*/ 87 | branches: 88 | ignore: /.*/ 89 | -------------------------------------------------------------------------------- /styles/mosaic-window.less: -------------------------------------------------------------------------------- 1 | @import (reference) 'mixins'; 2 | 3 | .mosaic-window, 4 | .mosaic-preview { 5 | position: relative; 6 | display: flex; 7 | flex-direction: column; 8 | overflow: hidden; 9 | box-shadow: 0 0 1px fade(black, 20%); 10 | 11 | @toolbar-height: 30px; 12 | .mosaic-window-toolbar { 13 | z-index: 4; 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | flex-shrink: 0; 18 | height: @toolbar-height; 19 | background: white; 20 | box-shadow: 0 1px 1px fade(black, 20%); 21 | 22 | &.draggable { 23 | cursor: move; 24 | } 25 | } 26 | 27 | .mosaic-window-title { 28 | display: flex; 29 | align-items: center; 30 | height: 100%; 31 | padding-left: 15px; 32 | flex: 1; 33 | text-overflow: ellipsis; 34 | white-space: nowrap; 35 | overflow: hidden; 36 | min-height: 18px; 37 | } 38 | 39 | .mosaic-window-controls { 40 | display: flex; 41 | height: 100%; 42 | 43 | .separator { 44 | @separator-height: 20px; 45 | height: @separator-height; 46 | border-left: 1px solid black; 47 | margin: (@toolbar-height - @separator-height)/2 4px; 48 | } 49 | } 50 | 51 | .mosaic-window-body { 52 | position: relative; 53 | flex: 1; 54 | height: 0; 55 | background: white; 56 | z-index: 1; 57 | overflow: hidden; 58 | } 59 | 60 | .mosaic-window-additional-actions-bar { 61 | .absolute-fill(@top: @toolbar-height; @bottom: initial); 62 | height: 0; 63 | overflow: hidden; 64 | background: white; 65 | justify-content: flex-end; 66 | display: flex; 67 | z-index: 3; 68 | 69 | .@{ns}-button { 70 | margin: 0; 71 | 72 | &:after { 73 | display: none; 74 | } 75 | } 76 | } 77 | 78 | .mosaic-window-body-overlay { 79 | .absolute-fill(); 80 | opacity: 0; 81 | background: white; 82 | display: none; 83 | z-index: 2; 84 | } 85 | 86 | &.additional-controls-open { 87 | .mosaic-window-additional-actions-bar { 88 | height: @toolbar-height; 89 | } 90 | .mosaic-window-body-overlay { 91 | display: block; 92 | } 93 | } 94 | 95 | .mosaic-preview { 96 | height: 100%; 97 | width: 100%; 98 | position: absolute; 99 | z-index: 0; 100 | border: 1px solid black; 101 | max-height: 400px; 102 | 103 | .mosaic-window-body { 104 | display: flex; 105 | flex-direction: column; 106 | align-items: center; 107 | justify-content: center; 108 | } 109 | 110 | h4 { 111 | margin-bottom: 10px; 112 | } 113 | } 114 | } 115 | 116 | .mosaic:not(.mosaic-blueprint-theme) { 117 | .mosaic-default-control { 118 | &.close-button:before { 119 | content: 'Close'; 120 | } 121 | &.split-button:before { 122 | content: 'Split'; 123 | } 124 | &.replace-button:before { 125 | content: 'Replace'; 126 | } 127 | &.expand-button:before { 128 | content: 'Expand'; 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/MosaicRoot.tsx: -------------------------------------------------------------------------------- 1 | import flatten from 'lodash/flatten'; 2 | import React from 'react'; 3 | import { MosaicContext } from './contextTypes'; 4 | import { Split } from './Split'; 5 | import { MosaicBranch, MosaicDirection, MosaicKey, MosaicNode, ResizeOptions, TileRenderer } from './types'; 6 | import { BoundingBox } from './util/BoundingBox'; 7 | import { isParent } from './util/mosaicUtilities'; 8 | 9 | export interface MosaicRootProps { 10 | root: MosaicNode; 11 | renderTile: TileRenderer; 12 | resize?: ResizeOptions; 13 | } 14 | 15 | export class MosaicRoot extends React.PureComponent> { 16 | static contextType = MosaicContext; 17 | context!: MosaicContext; 18 | 19 | render() { 20 | const { root } = this.props; 21 | return
{this.renderRecursively(root, BoundingBox.empty(), [])}
; 22 | } 23 | 24 | private renderRecursively( 25 | node: MosaicNode, 26 | boundingBox: BoundingBox, 27 | path: MosaicBranch[], 28 | ): JSX.Element | JSX.Element[] { 29 | if (isParent(node)) { 30 | const splitPercentage = node.splitPercentage == null ? 50 : node.splitPercentage; 31 | const { first, second } = BoundingBox.split(boundingBox, splitPercentage, node.direction); 32 | return flatten( 33 | [ 34 | this.renderRecursively(node.first, first, path.concat('first')), 35 | this.renderSplit(node.direction, boundingBox, splitPercentage, path), 36 | this.renderRecursively(node.second, second, path.concat('second')), 37 | ].filter(nonNullElement), 38 | ); 39 | } else { 40 | return ( 41 |
42 | {this.props.renderTile(node, path)} 43 |
44 | ); 45 | } 46 | } 47 | 48 | private renderSplit( 49 | direction: MosaicDirection, 50 | boundingBox: BoundingBox, 51 | splitPercentage: number, 52 | path: MosaicBranch[], 53 | ) { 54 | const { resize } = this.props; 55 | if (resize !== 'DISABLED') { 56 | return ( 57 | this.onResize(percentage, path, true)} 64 | onRelease={(percentage) => this.onResize(percentage, path, false)} 65 | /> 66 | ); 67 | } else { 68 | return null; 69 | } 70 | } 71 | 72 | private onResize = (percentage: number, path: MosaicBranch[], suppressOnRelease: boolean) => { 73 | this.context.mosaicActions.updateTree( 74 | [ 75 | { 76 | path, 77 | spec: { 78 | splitPercentage: { 79 | $set: percentage, 80 | }, 81 | }, 82 | }, 83 | ], 84 | suppressOnRelease, 85 | ); 86 | }; 87 | } 88 | 89 | function nonNullElement(x: JSX.Element | JSX.Element[] | null): x is JSX.Element | JSX.Element[] { 90 | return x !== null; 91 | } 92 | -------------------------------------------------------------------------------- /docs/main.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | Copyright (c) 2018 Jed Watson. 3 | Licensed under the MIT License (MIT), see 4 | http://jedwatson.github.io/classnames 5 | */ 6 | 7 | /*! 8 | Copyright (C) 2013-2015 by Andrea Giammarchi - @WebReflection 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in 18 | all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | THE SOFTWARE. 27 | 28 | */ 29 | 30 | /** 31 | * @license 32 | * Copyright 2019 Kevin Verdieck, originally developed at Palantir Technologies, Inc. 33 | * 34 | * Licensed under the Apache License, Version 2.0 (the "License"); 35 | * you may not use this file except in compliance with the License. 36 | * You may obtain a copy of the License at 37 | * 38 | * http://www.apache.org/licenses/LICENSE-2.0 39 | * 40 | * Unless required by applicable law or agreed to in writing, software 41 | * distributed under the License is distributed on an "AS IS" BASIS, 42 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 43 | * See the License for the specific language governing permissions and 44 | * limitations under the License. 45 | */ 46 | 47 | /** 48 | * @license React 49 | * react-dom.production.min.js 50 | * 51 | * Copyright (c) Facebook, Inc. and its affiliates. 52 | * 53 | * This source code is licensed under the MIT license found in the 54 | * LICENSE file in the root directory of this source tree. 55 | */ 56 | 57 | /** 58 | * @license React 59 | * react-jsx-runtime.production.min.js 60 | * 61 | * Copyright (c) Facebook, Inc. and its affiliates. 62 | * 63 | * This source code is licensed under the MIT license found in the 64 | * LICENSE file in the root directory of this source tree. 65 | */ 66 | 67 | /** 68 | * @license React 69 | * react.production.min.js 70 | * 71 | * Copyright (c) Facebook, Inc. and its affiliates. 72 | * 73 | * This source code is licensed under the MIT license found in the 74 | * LICENSE file in the root directory of this source tree. 75 | */ 76 | 77 | /** 78 | * @license React 79 | * scheduler.production.min.js 80 | * 81 | * Copyright (c) Facebook, Inc. and its affiliates. 82 | * 83 | * This source code is licensed under the MIT license found in the 84 | * LICENSE file in the root directory of this source tree. 85 | */ 86 | -------------------------------------------------------------------------------- /src/util/BoundingBox.ts: -------------------------------------------------------------------------------- 1 | import { MosaicDirection } from '../types'; 2 | import { assertNever } from './assertNever'; 3 | 4 | // Each of these values is like the CSS property of the same name in percentages 5 | export interface BoundingBox { 6 | top: number; 7 | right: number; 8 | bottom: number; 9 | left: number; 10 | } 11 | 12 | export namespace BoundingBox { 13 | export function empty() { 14 | return { 15 | top: 0, 16 | right: 0, 17 | bottom: 0, 18 | left: 0, 19 | }; 20 | } 21 | 22 | export interface Split { 23 | first: BoundingBox; 24 | second: BoundingBox; 25 | } 26 | 27 | export interface Styles { 28 | top: string; 29 | right: string; 30 | bottom: string; 31 | left: string; 32 | } 33 | 34 | export function split(boundingBox: BoundingBox, relativeSplitPercentage: number, direction: MosaicDirection): Split { 35 | const absolutePercentage = getAbsoluteSplitPercentage(boundingBox, relativeSplitPercentage, direction); 36 | if (direction === 'column') { 37 | return { 38 | first: { 39 | ...boundingBox, 40 | bottom: 100 - absolutePercentage, 41 | }, 42 | second: { 43 | ...boundingBox, 44 | top: absolutePercentage, 45 | }, 46 | }; 47 | } else if (direction === 'row') { 48 | return { 49 | first: { 50 | ...boundingBox, 51 | right: 100 - absolutePercentage, 52 | }, 53 | second: { 54 | ...boundingBox, 55 | left: absolutePercentage, 56 | }, 57 | }; 58 | } else { 59 | return assertNever(direction); 60 | } 61 | } 62 | 63 | export function getAbsoluteSplitPercentage( 64 | boundingBox: BoundingBox, 65 | relativeSplitPercentage: number, 66 | direction: MosaicDirection, 67 | ): number { 68 | const { top, right, bottom, left } = boundingBox; 69 | if (direction === 'column') { 70 | const height = 100 - top - bottom; 71 | return (height * relativeSplitPercentage) / 100 + top; 72 | } else if (direction === 'row') { 73 | const width = 100 - right - left; 74 | return (width * relativeSplitPercentage) / 100 + left; 75 | } else { 76 | return assertNever(direction); 77 | } 78 | } 79 | 80 | export function getRelativeSplitPercentage( 81 | boundingBox: BoundingBox, 82 | absoluteSplitPercentage: number, 83 | direction: MosaicDirection, 84 | ): number { 85 | const { top, right, bottom, left } = boundingBox; 86 | if (direction === 'column') { 87 | const height = 100 - top - bottom; 88 | return ((absoluteSplitPercentage - top) / height) * 100; 89 | } else if (direction === 'row') { 90 | const width = 100 - right - left; 91 | return ((absoluteSplitPercentage - left) / width) * 100; 92 | } else { 93 | return assertNever(direction); 94 | } 95 | } 96 | 97 | export function asStyles({ top, right, bottom, left }: BoundingBox): Styles { 98 | return { 99 | top: `${top}%`, 100 | right: `${right}%`, 101 | bottom: `${bottom}%`, 102 | left: `${left}%`, 103 | }; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/contextTypes.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MosaicKey, MosaicNode, MosaicPath, MosaicUpdate } from './types'; 4 | 5 | /** 6 | * Mosaic provides functionality on the context for components within 7 | * Mosaic to affect the view state. 8 | */ 9 | 10 | /** 11 | * Context provided to everything within Mosaic 12 | */ 13 | export interface MosaicContext { 14 | mosaicActions: MosaicRootActions; 15 | mosaicId: string; 16 | blueprintNamespace: string; 17 | } 18 | 19 | /** 20 | * Context provided to everything within a Mosaic Window 21 | */ 22 | export interface MosaicWindowContext { 23 | blueprintNamespace: string; 24 | mosaicWindowActions: MosaicWindowActions; 25 | } 26 | 27 | /** 28 | * These actions are used to alter the state of the view tree 29 | */ 30 | export interface MosaicRootActions { 31 | /** 32 | * Increases the size of this node and bubbles up the tree 33 | * @param path Path to node to expand 34 | * @param percentage Every node in the path up to root will be expanded to this percentage 35 | */ 36 | expand: (path: MosaicPath, percentage?: number) => void; 37 | /** 38 | * Remove the node at `path` 39 | * @param path 40 | */ 41 | remove: (path: MosaicPath) => void; 42 | /** 43 | * Hide the node at `path` but keep it in the DOM. Used in Drag and Drop 44 | * @param path 45 | */ 46 | hide: (path: MosaicPath) => void; 47 | /** 48 | * Replace currentNode at `path` with `node` 49 | * @param path 50 | * @param node 51 | */ 52 | replaceWith: (path: MosaicPath, node: MosaicNode) => void; 53 | /** 54 | * Atomically applies all updates to the current tree 55 | * @param updates 56 | * @param suppressOnRelease (default: false) 57 | */ 58 | updateTree: (updates: MosaicUpdate[], suppressOnRelease?: boolean) => void; 59 | /** 60 | * Returns the root of this Mosaic instance 61 | */ 62 | getRoot: () => MosaicNode | null; 63 | } 64 | 65 | export interface MosaicWindowActions { 66 | /** 67 | * Fails if no `createNode()` is defined 68 | * Creates a new node and splits the current node. 69 | * The current node becomes the `first` and the new node the `second` of the result. 70 | * `direction` is chosen by querying the DOM and splitting along the longer axis 71 | */ 72 | split: (...args: any[]) => Promise; 73 | /** 74 | * Fails if no `createNode()` is defined 75 | * Convenience function to call `createNode()` and replace the current node with it. 76 | */ 77 | replaceWithNew: (...args: any[]) => Promise; 78 | /** 79 | * Sets the open state for the tray that holds additional controls. 80 | * Pass 'toggle' to invert the current state. 81 | */ 82 | setAdditionalControlsOpen: (open: boolean | 'toggle') => void; 83 | /** 84 | * Returns the path to this window 85 | */ 86 | getPath: () => MosaicPath; 87 | /** 88 | * Enables connecting a different drag source besides the react-mosaic toolbar 89 | */ 90 | connectDragSource: (connectedElements: React.ReactElement) => React.ReactElement | null; 91 | } 92 | 93 | export const MosaicContext = React.createContext>(undefined!); 94 | export const MosaicWindowContext = React.createContext(undefined!); 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-mosaic-component", 3 | "version": "6.1.1", 4 | "description": "A React Tiling Window Manager", 5 | "license": "Apache-2.0", 6 | "main": "lib/index.js", 7 | "style": "lib/react-mosaic.css", 8 | "type": "commonjs", 9 | "typings": "lib/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/nomcopter/react-mosaic.git" 13 | }, 14 | "funding": "https://github.com/nomcopter/react-mosaic?sponsor=1", 15 | "keywords": [ 16 | "ui", 17 | "react", 18 | "component", 19 | "typescript", 20 | "tiling-window-manager", 21 | "window-manager" 22 | ], 23 | "sideEffects": [ 24 | "*.css" 25 | ], 26 | "scripts": { 27 | "build": "npm-run-all clean -lp build:**", 28 | "build:ts": "tsc -p tsconfig-build.json", 29 | "build:less": "lessc --autoprefix=defaults styles/index.less react-mosaic-component.css", 30 | "bundle": "rm -rf docs/ && webpack --config webpack/bundle.ts", 31 | "clean": "rm -rf lib/", 32 | "start": "webpack-dev-server --config webpack/hot.ts", 33 | "prettier:run": "prettier 'styles/*.less' '*.md' '{,.}*.yml' '.circleci/*.yml'", 34 | "test": "npm-run-all build -lp test:**", 35 | "test:lint": "tslint -c tslint.yml -p tsconfig.json -e test", 36 | "test:unit": "mocha", 37 | "test:format": "yarn run prettier:run --list-different", 38 | "fix": "npm-run-all -lp fix:**", 39 | "fix:format": "yarn run prettier:run --write", 40 | "fix:lint": "yarn run test:lint --fix", 41 | "version": "npm-run-all test bundle && git add -A docs/" 42 | }, 43 | "dependencies": { 44 | "classnames": "^2.3.2", 45 | "immutability-helper": "^3.1.1", 46 | "lodash": "^4.17.21", 47 | "prop-types": "^15.8.1", 48 | "rdndmb-html5-to-touch": "^8.0.0", 49 | "react-dnd": "^16.0.1", 50 | "react-dnd-html5-backend": "^16.0.1", 51 | "react-dnd-multi-backend": "^8.0.0", 52 | "react-dnd-touch-backend": "^16.0.1", 53 | "uuid": "^9.0.0" 54 | }, 55 | "devDependencies": { 56 | "@blueprintjs/core": "^4.15.1", 57 | "@blueprintjs/icons": "^4.13.1", 58 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", 59 | "@types/chai": "^4.3.0", 60 | "@types/classnames": "^2.3.1", 61 | "@types/dom4": "^2.0.2", 62 | "@types/lodash": "^4.14.191", 63 | "@types/mocha": "^7.0.2", 64 | "@types/prop-types": "^15.7.5", 65 | "@types/react": "^18.0.28", 66 | "@types/react-dom": "^18.0.10", 67 | "@types/uuid": "^9.0.0", 68 | "@types/webpack": "^5.28.0", 69 | "chai": "^4.3.6", 70 | "css-loader": "^6.7.3", 71 | "dnd-core": "16.0.1", 72 | "html-loader": "^4.2.0", 73 | "html-webpack-plugin": "^5.5.0", 74 | "jsdom": "^15.2.1", 75 | "jsdom-global": "^3.0.2", 76 | "less": "^3.13.1", 77 | "less-loader": "^11.1.0", 78 | "less-plugin-autoprefix": "^2.0.0", 79 | "mocha": "^6.2.3", 80 | "mocha-junit-reporter": "^1.23.3", 81 | "mock-require": "^3.0.3", 82 | "npm-run-all": "^4.1.5", 83 | "prettier": "^2.8.4", 84 | "react": "^18.2.0", 85 | "react-dom": "^18.2.0", 86 | "react-refresh": "^0.14.0", 87 | "source-map-loader": "^4.0.1", 88 | "style-loader": "^3.3.1", 89 | "ts-loader": "^9.4.2", 90 | "ts-node": "^10.9.1", 91 | "tslint": "^6.1.3", 92 | "tslint-config-prettier": "^1.18.0", 93 | "tslint-plugin-prettier": "^2.3.0", 94 | "tslint-react": "^5.0.0", 95 | "typescript": "^4.9.5", 96 | "webpack": "^5.75.0", 97 | "webpack-cli": "^5.0.1", 98 | "webpack-dev-server": "^4.11.1", 99 | "yarn-deduplicate": "^6.0.1" 100 | }, 101 | "peerDependencies": { 102 | "react": ">=16" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /styles/blueprint-theme.less: -------------------------------------------------------------------------------- 1 | @import (reference) '../node_modules/@blueprintjs/core/lib/less/variables'; 2 | 3 | .mosaic.mosaic-blueprint-theme { 4 | background: @gray4; 5 | 6 | .mosaic-zero-state { 7 | background: @light-gray3; 8 | border-radius: @pt-border-radius; 9 | box-shadow: @pt-elevation-shadow-0; 10 | 11 | .default-zero-state-icon { 12 | font-size: 120px; 13 | } 14 | } 15 | 16 | .mosaic-split:hover { 17 | background: none; 18 | .mosaic-split-line { 19 | box-shadow: 0 0 0 1px @blue4; 20 | } 21 | } 22 | 23 | &.mosaic-drop-target, 24 | .mosaic-drop-target { 25 | .drop-target-container .drop-target { 26 | background: fade(@blue5, 20%); 27 | border: 2px solid @blue4; 28 | transition: opacity 100ms; 29 | border-radius: @pt-border-radius; 30 | } 31 | } 32 | 33 | .mosaic-window, 34 | .mosaic-preview { 35 | box-shadow: @pt-elevation-shadow-0; 36 | border-radius: @pt-border-radius; 37 | 38 | .mosaic-window-toolbar { 39 | box-shadow: 0 1px 1px @pt-divider-black; 40 | border-top-right-radius: @pt-border-radius; 41 | border-top-left-radius: @pt-border-radius; 42 | 43 | &.draggable:hover { 44 | .mosaic-window-title { 45 | color: @black; 46 | } 47 | background: linear-gradient(to bottom, @white, @light-gray5); 48 | } 49 | } 50 | 51 | .mosaic-window-title { 52 | font-weight: 600; 53 | color: @dark-gray5; 54 | } 55 | 56 | .mosaic-window-controls { 57 | .separator { 58 | border-left: 1px solid @light-gray2; 59 | } 60 | .@{ns}-button { 61 | &, 62 | &:before { 63 | color: @gray2; 64 | } 65 | } 66 | } 67 | 68 | .default-preview-icon { 69 | font-size: 72px; 70 | } 71 | 72 | .mosaic-window-body { 73 | border-top-width: 0; 74 | background: @pt-app-background-color; 75 | border-bottom-right-radius: @pt-border-radius; 76 | border-bottom-left-radius: @pt-border-radius; 77 | } 78 | 79 | .mosaic-window-additional-actions-bar { 80 | transition: height 250ms; 81 | box-shadow: 0 1px 1px @pt-divider-black; 82 | 83 | .@{ns}-button { 84 | &, 85 | &:before { 86 | color: @gray2; 87 | } 88 | } 89 | } 90 | 91 | &.additional-controls-open { 92 | .mosaic-window-toolbar { 93 | box-shadow: 0 1px 0 @pt-elevation-shadow-0; 94 | } 95 | } 96 | 97 | .mosaic-preview { 98 | border: 1px solid @gray3; 99 | 100 | h4 { 101 | color: @dark-gray5; 102 | } 103 | } 104 | } 105 | 106 | &.@{ns}-dark { 107 | background: @dark-gray2; 108 | 109 | .mosaic-zero-state { 110 | background: @dark-gray4; 111 | box-shadow: @pt-dark-elevation-shadow-0; 112 | } 113 | 114 | .mosaic-split:hover .mosaic-split-line { 115 | box-shadow: 0 0 0 1px @blue3; 116 | } 117 | 118 | &.mosaic-drop-target, 119 | .mosaic-drop-target { 120 | .drop-target-container .drop-target { 121 | background: fade(@blue2, 20%); 122 | border-color: @blue3; 123 | } 124 | } 125 | 126 | .mosaic-window-toolbar, 127 | .mosaic-window-additional-actions-bar { 128 | background: @dark-gray4; 129 | box-shadow: 0 1px 1px @pt-dark-divider-black; 130 | } 131 | 132 | .mosaic-window, 133 | .mosaic-preview { 134 | box-shadow: @pt-dark-elevation-shadow-0; 135 | 136 | .mosaic-window-toolbar.draggable:hover { 137 | .mosaic-window-title { 138 | color: @white; 139 | } 140 | background: linear-gradient(to bottom, @dark-gray5, @dark-gray4); 141 | } 142 | 143 | .mosaic-window-title { 144 | color: @light-gray2; 145 | } 146 | 147 | .mosaic-window-controls { 148 | .separator { 149 | border-color: @gray1; 150 | } 151 | .@{ns}-button { 152 | &, 153 | &:before { 154 | color: @gray4; 155 | } 156 | } 157 | } 158 | 159 | .mosaic-window-body { 160 | background: @pt-dark-app-background-color; 161 | } 162 | 163 | .mosaic-window-additional-actions-bar { 164 | .@{ns}-button { 165 | &, 166 | &:before { 167 | color: @gray5; 168 | } 169 | } 170 | } 171 | 172 | &.additional-controls-open { 173 | .mosaic-window-toolbar { 174 | box-shadow: @pt-dark-elevation-shadow-0; 175 | } 176 | } 177 | 178 | .mosaic-preview { 179 | border-color: @gray1; 180 | 181 | h4 { 182 | color: @light-gray4; 183 | } 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/util/mosaicUtilities.ts: -------------------------------------------------------------------------------- 1 | import clone from 'lodash/clone'; 2 | import get from 'lodash/get'; 3 | import { MosaicBranch, MosaicDirection, MosaicKey, MosaicNode, MosaicParent, MosaicPath } from '../types'; 4 | 5 | function alternateDirection( 6 | node: MosaicNode, 7 | direction: MosaicDirection = 'row', 8 | ): MosaicNode { 9 | if (isParent(node)) { 10 | const nextDirection = getOtherDirection(direction); 11 | return { 12 | direction, 13 | first: alternateDirection(node.first, nextDirection), 14 | second: alternateDirection(node.second, nextDirection), 15 | }; 16 | } else { 17 | return node; 18 | } 19 | } 20 | 21 | export enum Corner { 22 | TOP_LEFT = 1, 23 | TOP_RIGHT, 24 | BOTTOM_LEFT, 25 | BOTTOM_RIGHT, 26 | } 27 | 28 | /** 29 | * Returns `true` if `node` is a MosaicParent 30 | * @param node 31 | * @returns {boolean} 32 | */ 33 | export function isParent(node: MosaicNode): node is MosaicParent { 34 | return (node as MosaicParent).direction != null; 35 | } 36 | 37 | /** 38 | * Creates a balanced binary tree from `leaves` with the goal of making them as equal area as possible 39 | * @param leaves 40 | * @param startDirection 41 | * @returns {MosaicNode} 42 | */ 43 | export function createBalancedTreeFromLeaves( 44 | leaves: MosaicNode[], 45 | startDirection: MosaicDirection = 'row', 46 | ): MosaicNode | null { 47 | if (leaves.length === 0) { 48 | return null; 49 | } 50 | 51 | let current: MosaicNode[] = clone(leaves); 52 | let next: MosaicNode[] = []; 53 | 54 | while (current.length > 1) { 55 | while (current.length > 0) { 56 | if (current.length > 1) { 57 | next.push({ 58 | direction: 'row', 59 | first: current.shift()!, 60 | second: current.shift()!, 61 | }); 62 | } else { 63 | next.unshift(current.shift()!); 64 | } 65 | } 66 | current = next; 67 | next = []; 68 | } 69 | return alternateDirection(current[0], startDirection); 70 | } 71 | 72 | /** 73 | * Gets the sibling of `branch` 74 | * @param branch 75 | * @returns {any} 76 | */ 77 | export function getOtherBranch(branch: MosaicBranch): MosaicBranch { 78 | if (branch === 'first') { 79 | return 'second'; 80 | } else if (branch === 'second') { 81 | return 'first'; 82 | } else { 83 | throw new Error(`Branch '${branch}' not a valid branch`); 84 | } 85 | } 86 | 87 | /** 88 | * Gets the opposite of `direction` 89 | * @param direction 90 | * @returns {any} 91 | */ 92 | export function getOtherDirection(direction: MosaicDirection): MosaicDirection { 93 | if (direction === 'row') { 94 | return 'column'; 95 | } else { 96 | return 'row'; 97 | } 98 | } 99 | 100 | /** 101 | * Traverses `tree` to find the path to the specified `corner` 102 | * @param tree 103 | * @param corner 104 | * @returns {MosaicPath} 105 | */ 106 | export function getPathToCorner(tree: MosaicNode, corner: Corner): MosaicPath { 107 | let currentNode: MosaicNode = tree; 108 | const currentPath: MosaicPath = []; 109 | while (isParent(currentNode)) { 110 | if (currentNode.direction === 'row' && (corner === Corner.TOP_LEFT || corner === Corner.BOTTOM_LEFT)) { 111 | currentPath.push('first'); 112 | currentNode = currentNode.first; 113 | } else if (currentNode.direction === 'column' && (corner === Corner.TOP_LEFT || corner === Corner.TOP_RIGHT)) { 114 | currentPath.push('first'); 115 | currentNode = currentNode.first; 116 | } else { 117 | currentPath.push('second'); 118 | currentNode = currentNode.second; 119 | } 120 | } 121 | 122 | return currentPath; 123 | } 124 | 125 | /** 126 | * Gets all leaves of `tree` 127 | * @param tree 128 | * @returns {T[]} 129 | */ 130 | export function getLeaves(tree: MosaicNode | null): T[] { 131 | if (tree == null) { 132 | return []; 133 | } else if (isParent(tree)) { 134 | return getLeaves(tree.first).concat(getLeaves(tree.second)); 135 | } else { 136 | return [tree]; 137 | } 138 | } 139 | 140 | /** 141 | * Gets node at `path` from `tree` 142 | * @param tree 143 | * @param path 144 | * @returns {MosaicNode|null} 145 | */ 146 | export function getNodeAtPath(tree: MosaicNode | null, path: MosaicPath): MosaicNode | null { 147 | if (path.length > 0) { 148 | return get(tree, path, null); 149 | } else { 150 | return tree; 151 | } 152 | } 153 | 154 | /** 155 | * Gets node at `path` from `tree` and verifies that neither `tree` nor the result are null 156 | * @param tree 157 | * @param path 158 | * @returns {MosaicNode} 159 | */ 160 | export function getAndAssertNodeAtPathExists( 161 | tree: MosaicNode | null, 162 | path: MosaicPath, 163 | ): MosaicNode { 164 | if (tree == null) { 165 | throw new Error('Root is empty, cannot fetch path'); 166 | } 167 | const node = getNodeAtPath(tree, path); 168 | if (node == null) { 169 | throw new Error(`Path [${path.join(', ')}] did not resolve to a node`); 170 | } 171 | return node; 172 | } 173 | -------------------------------------------------------------------------------- /test/updatesSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { getNodeAtPath, MosaicNode } from '../src/index'; 3 | import { MosaicDropTargetPosition } from '../src/internalTypes'; 4 | import { MosaicParent, MosaicPath } from '../src/types'; 5 | import { createDragToUpdates, createRemoveUpdate, updateTree } from '../src/util/mosaicUpdates'; 6 | 7 | const MEDIUM_TREE: MosaicNode = { 8 | direction: 'row', 9 | first: 1, 10 | second: { 11 | direction: 'column', 12 | first: { 13 | direction: 'column', 14 | first: 2, 15 | second: 3, 16 | }, 17 | second: 4, 18 | }, 19 | }; 20 | 21 | describe('mosaicUpdates', () => { 22 | describe('updateTree', () => { 23 | const simpleUpdatedTree = updateTree(MEDIUM_TREE, [ 24 | { 25 | path: ['first'], 26 | spec: { 27 | $set: 5, 28 | }, 29 | }, 30 | ]); 31 | it('should apply update', () => { 32 | expect(getNodeAtPath(simpleUpdatedTree, ['first'])).to.equal(5); 33 | }); 34 | it('roots should not be reference equal', () => { 35 | expect(simpleUpdatedTree).to.not.equal(MEDIUM_TREE); 36 | }); 37 | it('unchanged nodes should be reference equal', () => { 38 | const path: MosaicPath = ['second']; 39 | expect(getNodeAtPath(simpleUpdatedTree, path)).to.equal(getNodeAtPath(MEDIUM_TREE, path)); 40 | }); 41 | }); 42 | describe('createRemoveUpdate', () => { 43 | it('should fail on null', () => { 44 | expect(() => createRemoveUpdate(MEDIUM_TREE, ['first', 'first'])).to.throw(Error); 45 | }); 46 | it('should remove leaf', () => { 47 | const updatedTree = updateTree(MEDIUM_TREE, [createRemoveUpdate(MEDIUM_TREE, ['second', 'second'])]); 48 | expect(getNodeAtPath(updatedTree, ['second'])).to.equal(getNodeAtPath(MEDIUM_TREE, ['second', 'first'])); 49 | }); 50 | it('should fail to remove root', () => { 51 | expect(() => updateTree(MEDIUM_TREE, [createRemoveUpdate(MEDIUM_TREE, [])])).to.throw(Error); 52 | }); 53 | it('should fail to remove non-existant node', () => { 54 | expect(() => updateTree(MEDIUM_TREE, [createRemoveUpdate(MEDIUM_TREE, ['first', 'first'])])).to.throw(Error); 55 | }); 56 | }); 57 | describe('createDragToUpdates', () => { 58 | describe('drag leaf to unrelated leaf', () => { 59 | const updatedTree = updateTree( 60 | MEDIUM_TREE, 61 | createDragToUpdates( 62 | MEDIUM_TREE, 63 | ['second', 'first', 'second'], 64 | ['second', 'second'], 65 | MosaicDropTargetPosition.RIGHT, 66 | ), 67 | ); 68 | it('should remove sourceNode', () => { 69 | expect(getNodeAtPath(updatedTree, ['second', 'first', 'second'])).to.equal(null); 70 | }); 71 | it('should make source parent a leaf', () => { 72 | expect(getNodeAtPath(updatedTree, ['second', 'first'])).to.equal(2); 73 | }); 74 | it('source should be in destination', () => { 75 | expect(getNodeAtPath(updatedTree, ['second', 'second', 'second'])).to.equal(3); 76 | }); 77 | it('destination should be a sibling', () => { 78 | expect(getNodeAtPath(updatedTree, ['second', 'second', 'first'])).to.equal(4); 79 | }); 80 | it('direction should be correct', () => { 81 | expect((getNodeAtPath(updatedTree, ['second', 'second']) as MosaicParent).direction).to.equal('row'); 82 | }); 83 | }); 84 | describe('drag leaf to unrelated parent', () => { 85 | const updatedTree = updateTree( 86 | MEDIUM_TREE, 87 | createDragToUpdates(MEDIUM_TREE, ['first'], ['second', 'first'], MosaicDropTargetPosition.TOP), 88 | ); 89 | it('should remove sourceNode', () => { 90 | expect(getNodeAtPath(updatedTree, ['first'])).to.not.equal(1); 91 | }); 92 | it('source should be in destination', () => { 93 | expect(getNodeAtPath(updatedTree, ['first', 'first'])).to.equal(1); 94 | }); 95 | it('destination should be a sibling', () => { 96 | expect(getNodeAtPath(updatedTree, ['first', 'second', 'first'])).to.equal(2); 97 | }); 98 | it('direction should be correct', () => { 99 | expect((getNodeAtPath(updatedTree, ['first']) as MosaicParent).direction).to.equal('column'); 100 | }); 101 | }); 102 | describe('drag leaf to root', () => { 103 | const updatedTree = updateTree( 104 | MEDIUM_TREE, 105 | createDragToUpdates(MEDIUM_TREE, ['second', 'second'], [], MosaicDropTargetPosition.RIGHT), 106 | ); 107 | it('should remove sourceNode', () => { 108 | expect(getNodeAtPath(updatedTree, ['first', 'second', 'second'])).to.equal(3); 109 | }); 110 | it('source should be in destination', () => { 111 | expect(getNodeAtPath(updatedTree, ['second'])).to.equal(4); 112 | }); 113 | it('destination should be a sibling', () => { 114 | expect(getNodeAtPath(updatedTree, ['first', 'first'])).to.equal(1); 115 | }); 116 | it('direction should be correct', () => { 117 | expect((getNodeAtPath(updatedTree, []) as MosaicParent).direction).to.equal('row'); 118 | }); 119 | }); 120 | }); 121 | // TODO: createHideUpdate 122 | // TODO: createExpandUpdate 123 | }); 124 | -------------------------------------------------------------------------------- /src/Split.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import clamp from 'lodash/clamp'; 3 | import throttle from 'lodash/throttle'; 4 | import React from 'react'; 5 | 6 | import { EnabledResizeOptions, MosaicDirection } from './types'; 7 | import { BoundingBox } from './util/BoundingBox'; 8 | 9 | const RESIZE_THROTTLE_MS = 1000 / 30; // 30 fps 10 | 11 | const TOUCH_EVENT_OPTIONS = { 12 | capture: true, 13 | passive: false, 14 | }; 15 | 16 | export interface SplitProps extends EnabledResizeOptions { 17 | direction: MosaicDirection; 18 | boundingBox: BoundingBox; 19 | splitPercentage: number; 20 | onChange?: (percentOfParent: number) => void; 21 | onRelease?: (percentOfParent: number) => void; 22 | } 23 | 24 | export class Split extends React.PureComponent { 25 | private rootElement = React.createRef(); 26 | private listenersBound = false; 27 | 28 | static defaultProps = { 29 | onChange: () => void 0, 30 | onRelease: () => void 0, 31 | minimumPaneSizePercentage: 20, 32 | }; 33 | 34 | render() { 35 | const { direction } = this.props; 36 | return ( 37 |
46 |
47 |
48 | ); 49 | } 50 | 51 | componentDidMount() { 52 | this.rootElement.current!.addEventListener('touchstart', this.onMouseDown, TOUCH_EVENT_OPTIONS); 53 | } 54 | 55 | componentWillUnmount() { 56 | this.unbindListeners(); 57 | if (this.rootElement.current) { 58 | this.rootElement.current.ownerDocument!.removeEventListener('touchstart', this.onMouseDown, TOUCH_EVENT_OPTIONS); 59 | } 60 | } 61 | 62 | private bindListeners() { 63 | if (!this.listenersBound) { 64 | this.rootElement.current!.ownerDocument!.addEventListener('mousemove', this.onMouseMove, true); 65 | this.rootElement.current!.ownerDocument!.addEventListener('touchmove', this.onMouseMove, TOUCH_EVENT_OPTIONS); 66 | this.rootElement.current!.ownerDocument!.addEventListener('mouseup', this.onMouseUp, true); 67 | this.rootElement.current!.ownerDocument!.addEventListener('touchend', this.onMouseUp, true); 68 | this.listenersBound = true; 69 | } 70 | } 71 | 72 | private unbindListeners() { 73 | if (this.rootElement.current) { 74 | this.rootElement.current.ownerDocument!.removeEventListener('mousemove', this.onMouseMove, true); 75 | this.rootElement.current.ownerDocument!.removeEventListener('touchmove', this.onMouseMove, TOUCH_EVENT_OPTIONS); 76 | this.rootElement.current.ownerDocument!.removeEventListener('mouseup', this.onMouseUp, true); 77 | this.rootElement.current.ownerDocument!.removeEventListener('touchend', this.onMouseUp, true); 78 | this.listenersBound = false; 79 | } 80 | } 81 | 82 | private computeStyle() { 83 | const { boundingBox, direction, splitPercentage } = this.props; 84 | const positionStyle = direction === 'column' ? 'top' : 'left'; 85 | const absolutePercentage = BoundingBox.getAbsoluteSplitPercentage(boundingBox, splitPercentage, direction); 86 | return { 87 | ...BoundingBox.asStyles(boundingBox), 88 | [positionStyle]: `${absolutePercentage}%`, 89 | }; 90 | } 91 | 92 | private onMouseDown = (event: React.MouseEvent | TouchEvent) => { 93 | if (!isTouchEvent(event)) { 94 | if (event.button !== 0) { 95 | return; 96 | } 97 | } 98 | 99 | event.preventDefault(); 100 | this.bindListeners(); 101 | }; 102 | 103 | private onMouseUp = (event: MouseEvent | TouchEvent) => { 104 | this.unbindListeners(); 105 | 106 | const percentage = this.calculateRelativePercentage(event); 107 | this.props.onRelease!(percentage); 108 | }; 109 | 110 | private onMouseMove = (event: MouseEvent | TouchEvent) => { 111 | event.preventDefault(); 112 | 113 | this.throttledUpdatePercentage(event); 114 | }; 115 | 116 | private throttledUpdatePercentage = throttle((event: MouseEvent | TouchEvent) => { 117 | const percentage = this.calculateRelativePercentage(event); 118 | if (percentage !== this.props.splitPercentage) { 119 | this.props.onChange!(percentage); 120 | } 121 | }, RESIZE_THROTTLE_MS); 122 | 123 | private calculateRelativePercentage(event: MouseEvent | TouchEvent): number { 124 | const { minimumPaneSizePercentage, direction, boundingBox } = this.props; 125 | const parentBBox = this.rootElement.current!.parentElement!.getBoundingClientRect(); 126 | const location = isTouchEvent(event) ? event.changedTouches[0] : event; 127 | 128 | let absolutePercentage: number; 129 | if (direction === 'column') { 130 | absolutePercentage = ((location.clientY - parentBBox.top) / parentBBox.height) * 100.0; 131 | } else { 132 | absolutePercentage = ((location.clientX - parentBBox.left) / parentBBox.width) * 100.0; 133 | } 134 | 135 | const relativePercentage = BoundingBox.getRelativeSplitPercentage(boundingBox, absolutePercentage, direction); 136 | 137 | return clamp(relativePercentage, minimumPaneSizePercentage!, 100 - minimumPaneSizePercentage!); 138 | } 139 | } 140 | 141 | function isTouchEvent(event: MouseEvent | TouchEvent | React.MouseEvent): event is TouchEvent { 142 | return (event as TouchEvent).changedTouches != null; 143 | } 144 | -------------------------------------------------------------------------------- /test/utilitiesSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import max from 'lodash/max'; 3 | import min from 'lodash/min'; 4 | import range from 'lodash/range'; 5 | 6 | import { getNodeAtPath, MosaicNode } from '../src/index'; 7 | import { 8 | Corner, 9 | createBalancedTreeFromLeaves, 10 | getAndAssertNodeAtPathExists, 11 | getLeaves, 12 | getPathToCorner, 13 | isParent, 14 | } from '../src/util/mosaicUtilities'; 15 | 16 | const ROOT_ONLY_TREE: MosaicNode = 1; 17 | const MEDIUM_TREE: MosaicNode = { 18 | direction: 'row', 19 | first: 1, 20 | second: { 21 | direction: 'column', 22 | first: { 23 | direction: 'column', 24 | first: 2, 25 | second: 3, 26 | }, 27 | second: 4, 28 | }, 29 | }; 30 | 31 | const FALSY_TREE: MosaicNode = { 32 | direction: 'row', 33 | first: 0, 34 | second: '', 35 | }; 36 | 37 | const NINE_LEAVES = range(1, 10); 38 | const THOUSAND_AND_ONE_LEAVES = range(1, 1002); 39 | 40 | const NUMERICAL_SORT = (a: number, b: number) => a - b; 41 | 42 | function getTreeDepths(tree: MosaicNode): { min: number; max: number } { 43 | if (isParent(tree)) { 44 | const first = getTreeDepths(tree.first); 45 | const second = getTreeDepths(tree.second); 46 | return { 47 | min: min([first.min, second.min])! + 1, 48 | max: max([first.max, second.max])! + 1, 49 | }; 50 | } else { 51 | return { 52 | min: 0, 53 | max: 0, 54 | }; 55 | } 56 | } 57 | 58 | describe('mosaicUtilities', () => { 59 | describe('getNodeAtPath', () => { 60 | it('should get root', () => { 61 | expect(getNodeAtPath(MEDIUM_TREE, [])).to.equal(MEDIUM_TREE); 62 | }); 63 | it('should get MosaicParent', () => { 64 | expect(getNodeAtPath(MEDIUM_TREE, ['second'])).to.equal(MEDIUM_TREE.second); 65 | }); 66 | it('should get leaf', () => { 67 | expect(getNodeAtPath(MEDIUM_TREE, ['second', 'first', 'second'])).to.equal(3); 68 | }); 69 | it('should return null on incorrect path', () => { 70 | expect(getNodeAtPath(MEDIUM_TREE, ['second', 'first', 'second', 'first'])).to.equal(null); 71 | }); 72 | it('should return null on null root', () => { 73 | expect(getNodeAtPath(null, ['second', 'first', 'second', 'first'])).to.equal(null); 74 | }); 75 | it('should work with falsy values', () => { 76 | expect(getNodeAtPath(FALSY_TREE, ['first'])).to.equal(0); 77 | }); 78 | }); 79 | describe('getAndAssertNodeAtPathExists', () => { 80 | it('should get root', () => { 81 | expect(getAndAssertNodeAtPathExists(MEDIUM_TREE, [])).to.equal(MEDIUM_TREE); 82 | }); 83 | it('should get MosaicParent', () => { 84 | expect(getAndAssertNodeAtPathExists(MEDIUM_TREE, ['second'])).to.equal(MEDIUM_TREE.second); 85 | }); 86 | it('should get leaf', () => { 87 | expect(getAndAssertNodeAtPathExists(MEDIUM_TREE, ['second', 'first', 'second'])).to.equal(3); 88 | }); 89 | it('should error on incorrect path', () => { 90 | expect(() => getAndAssertNodeAtPathExists(MEDIUM_TREE, ['second', 'first', 'second', 'first'])).to.throw(Error); 91 | }); 92 | it('should error on null root', () => { 93 | expect(() => getAndAssertNodeAtPathExists(null, ['second', 'first', 'second', 'first'])).to.throw(Error); 94 | }); 95 | }); 96 | describe('getLeaves', () => { 97 | it('should get leaves of simple tree', () => { 98 | expect(getLeaves(ROOT_ONLY_TREE)).to.deep.equal([1]); 99 | }); 100 | it('should get leaves of medium tree', () => { 101 | expect(getLeaves(MEDIUM_TREE).sort(NUMERICAL_SORT)).to.deep.equal([1, 2, 3, 4]); 102 | }); 103 | it('should return empty array when provided an empty tree', () => { 104 | expect(getLeaves(null)).to.deep.equal([]); 105 | }); 106 | }); 107 | describe('createBalancedTreeFromLeaves', () => { 108 | it('should be balanced', () => { 109 | const tree = createBalancedTreeFromLeaves(NINE_LEAVES); 110 | const depths = getTreeDepths(tree); 111 | expect(depths.max - depths.min).to.be.lessThan(2); 112 | }); 113 | it('should be balanced when huge', () => { 114 | const tree = createBalancedTreeFromLeaves(THOUSAND_AND_ONE_LEAVES); 115 | const depths = getTreeDepths(tree); 116 | expect(depths.max - depths.min).to.be.lessThan(2); 117 | }); 118 | it('should include all leaves', () => { 119 | const tree = createBalancedTreeFromLeaves(THOUSAND_AND_ONE_LEAVES); 120 | const leaves = getLeaves(tree); 121 | expect(leaves.sort(NUMERICAL_SORT)).to.deep.equal(THOUSAND_AND_ONE_LEAVES); 122 | }); 123 | it('should return empty tree when provided no leaves', () => { 124 | const tree = createBalancedTreeFromLeaves([]); 125 | expect(tree).to.equal(null); 126 | }); 127 | }); 128 | describe('getPathToCorner', () => { 129 | it('should get top left', () => { 130 | const path = getPathToCorner(MEDIUM_TREE, Corner.TOP_LEFT); 131 | expect(getNodeAtPath(MEDIUM_TREE, path)).to.equal(1); 132 | }); 133 | it('should get top right', () => { 134 | const path = getPathToCorner(MEDIUM_TREE, Corner.TOP_RIGHT); 135 | expect(getNodeAtPath(MEDIUM_TREE, path)).to.equal(2); 136 | }); 137 | it('should get bottom left', () => { 138 | const path = getPathToCorner(MEDIUM_TREE, Corner.BOTTOM_LEFT); 139 | expect(getNodeAtPath(MEDIUM_TREE, path)).to.equal(1); 140 | }); 141 | it('should get bottom right', () => { 142 | const path = getPathToCorner(MEDIUM_TREE, Corner.BOTTOM_RIGHT); 143 | expect(getNodeAtPath(MEDIUM_TREE, path)).to.equal(4); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/util/mosaicUpdates.ts: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper'; 2 | import drop from 'lodash/drop'; 3 | import dropRight from 'lodash/dropRight'; 4 | import isEqual from 'lodash/isEqual'; 5 | import last from 'lodash/last'; 6 | import set from 'lodash/set'; 7 | import take from 'lodash/take'; 8 | import { MosaicDropTargetPosition } from '../internalTypes'; 9 | import { 10 | MosaicBranch, 11 | MosaicDirection, 12 | MosaicKey, 13 | MosaicNode, 14 | MosaicParent, 15 | MosaicPath, 16 | MosaicUpdate, 17 | MosaicUpdateSpec, 18 | } from '../types'; 19 | import { getAndAssertNodeAtPathExists, getOtherBranch } from './mosaicUtilities'; 20 | 21 | // https://github.com/Microsoft/TypeScript/issues/9944 22 | export { MosaicParent }; 23 | 24 | /** 25 | * Used to prepare `update` for `immutability-helper` 26 | * @param mosaicUpdate 27 | * @returns {any} 28 | */ 29 | export function buildSpecFromUpdate(mosaicUpdate: MosaicUpdate): MosaicUpdateSpec { 30 | if (mosaicUpdate.path.length > 0) { 31 | return set({}, mosaicUpdate.path, mosaicUpdate.spec); 32 | } else { 33 | return mosaicUpdate.spec; 34 | } 35 | } 36 | 37 | /** 38 | * Applies `updates` to `root` 39 | * @param root 40 | * @param updates 41 | * @returns {MosaicNode} 42 | */ 43 | export function updateTree(root: MosaicNode, updates: MosaicUpdate[]) { 44 | let currentNode = root; 45 | updates.forEach((mUpdate: MosaicUpdate) => { 46 | currentNode = update(currentNode as MosaicParent, buildSpecFromUpdate(mUpdate)); 47 | }); 48 | 49 | return currentNode; 50 | } 51 | 52 | /** 53 | * Creates a `MosaicUpdate` to remove the node at `path` from `root` 54 | * @param root 55 | * @param path 56 | * @returns {{path: T[], spec: {$set: MosaicNode}}} 57 | */ 58 | export function createRemoveUpdate(root: MosaicNode | null, path: MosaicPath): MosaicUpdate { 59 | const parentPath = dropRight(path); 60 | const nodeToRemove = last(path); 61 | const siblingPath = parentPath.concat(getOtherBranch(nodeToRemove!)); 62 | const sibling = getAndAssertNodeAtPathExists(root, siblingPath); 63 | 64 | return { 65 | path: parentPath, 66 | spec: { 67 | $set: sibling, 68 | }, 69 | }; 70 | } 71 | 72 | function isPathPrefixEqual(a: MosaicPath, b: MosaicPath, length: number) { 73 | return isEqual(take(a, length), take(b, length)); 74 | } 75 | 76 | /** 77 | * Creates a `MosaicUpdate` to split the _leaf_ at `destinationPath` into a node of it and the node from `sourcePath` 78 | * placing the node from `sourcePath` in `position`. 79 | * @param root 80 | * @param sourcePath 81 | * @param destinationPath 82 | * @param position 83 | * @returns {(MosaicUpdate|{path: MosaicPath, spec: {$set: {first: MosaicNode, second: MosaicNode, direction: MosaicDirection}}})[]} 84 | */ 85 | export function createDragToUpdates( 86 | root: MosaicNode, 87 | sourcePath: MosaicPath, 88 | destinationPath: MosaicPath, 89 | position: MosaicDropTargetPosition, 90 | ): MosaicUpdate[] { 91 | let destinationNode = getAndAssertNodeAtPathExists(root, destinationPath); 92 | const updates: MosaicUpdate[] = []; 93 | 94 | const destinationIsParentOfSource = isPathPrefixEqual(sourcePath, destinationPath, destinationPath.length); 95 | if (destinationIsParentOfSource) { 96 | // Must explicitly remove source from the destination node 97 | destinationNode = updateTree(destinationNode, [ 98 | createRemoveUpdate(destinationNode, drop(sourcePath, destinationPath.length)), 99 | ]); 100 | } else { 101 | // Can remove source normally 102 | updates.push(createRemoveUpdate(root, sourcePath)); 103 | 104 | // Have to drop in the correct destination after the source has been removed 105 | const removedNodeParentIsInPath = isPathPrefixEqual(sourcePath, destinationPath, sourcePath.length - 1); 106 | if (removedNodeParentIsInPath) { 107 | destinationPath.splice(sourcePath.length - 1, 1); 108 | } 109 | } 110 | 111 | const sourceNode = getAndAssertNodeAtPathExists(root, sourcePath); 112 | let first: MosaicNode; 113 | let second: MosaicNode; 114 | if (position === MosaicDropTargetPosition.LEFT || position === MosaicDropTargetPosition.TOP) { 115 | first = sourceNode; 116 | second = destinationNode; 117 | } else { 118 | first = destinationNode; 119 | second = sourceNode; 120 | } 121 | 122 | let direction: MosaicDirection = 'column'; 123 | if (position === MosaicDropTargetPosition.LEFT || position === MosaicDropTargetPosition.RIGHT) { 124 | direction = 'row'; 125 | } 126 | 127 | updates.push({ 128 | path: destinationPath, 129 | spec: { 130 | $set: { first, second, direction }, 131 | }, 132 | }); 133 | 134 | return updates; 135 | } 136 | 137 | /** 138 | * Sets the splitPercentage to hide the node at `path` 139 | * @param path 140 | * @returns {{path: T[], spec: {splitPercentage: {$set: number}}}} 141 | */ 142 | export function createHideUpdate(path: MosaicPath): MosaicUpdate { 143 | const targetPath = dropRight(path); 144 | const thisBranch = last(path); 145 | 146 | let splitPercentage: number; 147 | if (thisBranch === 'first') { 148 | splitPercentage = 0; 149 | } else { 150 | splitPercentage = 100; 151 | } 152 | 153 | return { 154 | path: targetPath, 155 | spec: { 156 | splitPercentage: { 157 | $set: splitPercentage, 158 | }, 159 | }, 160 | }; 161 | } 162 | 163 | /** 164 | * Sets the splitPercentage of node at `path` and all of its parents to `percentage` in order to expand it 165 | * @param path 166 | * @param percentage 167 | * @returns {{spec: MosaicUpdateSpec, path: Array}} 168 | */ 169 | export function createExpandUpdate(path: MosaicPath, percentage: number): MosaicUpdate { 170 | let spec: MosaicUpdateSpec = {}; 171 | for (let i = path.length - 1; i >= 0; i--) { 172 | const branch: MosaicBranch = path[i]; 173 | const splitPercentage = branch === 'first' ? percentage : 100 - percentage; 174 | spec = { 175 | splitPercentage: { 176 | $set: splitPercentage, 177 | }, 178 | [branch]: spec, 179 | }; 180 | } 181 | 182 | return { 183 | spec, 184 | path: [], 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /demo/ExampleApp.tsx: -------------------------------------------------------------------------------- 1 | import { Classes, HTMLSelect } from '@blueprintjs/core'; 2 | import { IconNames } from '@blueprintjs/icons'; 3 | import classNames from 'classnames'; 4 | import dropRight from 'lodash/dropRight'; 5 | import React from 'react'; 6 | 7 | import { 8 | Corner, 9 | createBalancedTreeFromLeaves, 10 | getLeaves, 11 | getNodeAtPath, 12 | getOtherDirection, 13 | getPathToCorner, 14 | Mosaic, 15 | MosaicBranch, 16 | MosaicDirection, 17 | MosaicNode, 18 | MosaicParent, 19 | MosaicWindow, 20 | MosaicZeroState, 21 | updateTree, 22 | } from '../src'; 23 | 24 | import { CloseAdditionalControlsButton } from './CloseAdditionalControlsButton'; 25 | 26 | import '@blueprintjs/core/lib/css/blueprint.css'; 27 | import '@blueprintjs/icons/lib/css/blueprint-icons.css'; 28 | import '../styles/index.less'; 29 | import './carbon.less'; 30 | import './example.less'; 31 | 32 | // tslint:disable no-console 33 | 34 | // tslint:disable-next-line no-var-requires 35 | const gitHubLogo = require('./GitHub-Mark-Light-32px.png'); 36 | // tslint:disable-next-line no-var-requires 37 | const { version } = require('../package.json'); 38 | 39 | export const THEMES = { 40 | ['Blueprint']: 'mosaic-blueprint-theme', 41 | ['Blueprint Dark']: classNames('mosaic-blueprint-theme', Classes.DARK), 42 | ['None']: '', 43 | }; 44 | 45 | export type Theme = keyof typeof THEMES; 46 | 47 | const additionalControls = React.Children.toArray([]); 48 | 49 | const EMPTY_ARRAY: any[] = []; 50 | 51 | export interface ExampleAppState { 52 | currentNode: MosaicNode | null; 53 | currentTheme: Theme; 54 | } 55 | 56 | export class ExampleApp extends React.PureComponent<{}, ExampleAppState> { 57 | state: ExampleAppState = { 58 | currentNode: { 59 | direction: 'row', 60 | first: 1, 61 | second: { 62 | direction: 'column', 63 | first: 2, 64 | second: 3, 65 | }, 66 | splitPercentage: 40, 67 | }, 68 | currentTheme: 'Blueprint', 69 | }; 70 | 71 | render() { 72 | const totalWindowCount = getLeaves(this.state.currentNode).length; 73 | return ( 74 | 75 |
76 | {this.renderNavBar()} 77 | 78 | renderTile={(count, path) => ( 79 | 80 | )} 81 | zeroStateView={ totalWindowCount + 1} />} 82 | value={this.state.currentNode} 83 | onChange={this.onChange} 84 | onRelease={this.onRelease} 85 | className={THEMES[this.state.currentTheme]} 86 | blueprintNamespace="bp4" 87 | /> 88 |
89 |
90 | ); 91 | } 92 | 93 | private onChange = (currentNode: MosaicNode | null) => { 94 | this.setState({ currentNode }); 95 | }; 96 | 97 | private onRelease = (currentNode: MosaicNode | null) => { 98 | console.log('Mosaic.onRelease():', currentNode); 99 | }; 100 | 101 | private autoArrange = () => { 102 | const leaves = getLeaves(this.state.currentNode); 103 | 104 | this.setState({ 105 | currentNode: createBalancedTreeFromLeaves(leaves), 106 | }); 107 | }; 108 | 109 | private addToTopRight = () => { 110 | let { currentNode } = this.state; 111 | const totalWindowCount = getLeaves(currentNode).length; 112 | if (currentNode) { 113 | const path = getPathToCorner(currentNode, Corner.TOP_RIGHT); 114 | const parent = getNodeAtPath(currentNode, dropRight(path)) as MosaicParent; 115 | const destination = getNodeAtPath(currentNode, path) as MosaicNode; 116 | const direction: MosaicDirection = parent ? getOtherDirection(parent.direction) : 'row'; 117 | 118 | let first: MosaicNode; 119 | let second: MosaicNode; 120 | if (direction === 'row') { 121 | first = destination; 122 | second = totalWindowCount + 1; 123 | } else { 124 | first = totalWindowCount + 1; 125 | second = destination; 126 | } 127 | 128 | currentNode = updateTree(currentNode, [ 129 | { 130 | path, 131 | spec: { 132 | $set: { 133 | direction, 134 | first, 135 | second, 136 | }, 137 | }, 138 | }, 139 | ]); 140 | } else { 141 | currentNode = totalWindowCount + 1; 142 | } 143 | 144 | this.setState({ currentNode }); 145 | }; 146 | 147 | private renderNavBar() { 148 | return ( 149 |
150 | 157 |
158 | 167 |
168 | Example Actions: 169 | 175 | 181 | 182 | 183 | 184 |
185 |
186 | ); 187 | } 188 | } 189 | 190 | interface ExampleWindowProps { 191 | count: number; 192 | path: MosaicBranch[]; 193 | totalWindowCount: number; 194 | } 195 | 196 | const ExampleWindow = ({ count, path, totalWindowCount }: ExampleWindowProps) => { 197 | const adContainer = React.useRef(null); 198 | React.useEffect(() => { 199 | if (adContainer.current == null) { 200 | return; 201 | } 202 | 203 | const script = document.createElement('script'); 204 | 205 | script.src = '//cdn.carbonads.com/carbon.js?serve=CEAIEK3E&placement=nomcoptergithubio'; 206 | script.async = true; 207 | script.type = 'text/javascript'; 208 | script.id = '_carbonads_js'; 209 | 210 | adContainer.current.appendChild(script); 211 | }, []); 212 | 213 | return ( 214 | 215 | additionalControls={count === 3 ? additionalControls : EMPTY_ARRAY} 216 | title={`Window ${count}`} 217 | createNode={() => totalWindowCount + 1} 218 | path={path} 219 | onDragStart={() => console.log('MosaicWindow.onDragStart')} 220 | onDragEnd={(type) => console.log('MosaicWindow.onDragEnd', type)} 221 | renderToolbar={count === 2 ? () =>
Custom Toolbar
: null} 222 | > 223 |
224 |

{`Window ${count}`}

225 | {count === 3 &&
} 226 |
227 | 228 | ); 229 | }; 230 | -------------------------------------------------------------------------------- /src/Mosaic.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { DragDropManager } from 'dnd-core'; 3 | import countBy from 'lodash/countBy'; 4 | import keys from 'lodash/keys'; 5 | import pickBy from 'lodash/pickBy'; 6 | import { HTML5toTouch } from 'rdndmb-html5-to-touch'; 7 | import React from 'react'; 8 | import { DndProvider } from 'react-dnd'; 9 | import { MultiBackend } from 'react-dnd-multi-backend'; 10 | import { v4 as uuid } from 'uuid'; 11 | 12 | import { MosaicContext, MosaicRootActions } from './contextTypes'; 13 | import { MosaicRoot } from './MosaicRoot'; 14 | import { MosaicZeroState } from './MosaicZeroState'; 15 | import { RootDropTargets } from './RootDropTargets'; 16 | import { MosaicKey, MosaicNode, MosaicPath, MosaicUpdate, ResizeOptions, TileRenderer } from './types'; 17 | import { createExpandUpdate, createHideUpdate, createRemoveUpdate, updateTree } from './util/mosaicUpdates'; 18 | import { getLeaves } from './util/mosaicUtilities'; 19 | 20 | const DEFAULT_EXPAND_PERCENTAGE = 70; 21 | 22 | export interface MosaicBaseProps { 23 | /** 24 | * Lookup function to convert `T` to a displayable `JSX.Element` 25 | */ 26 | renderTile: TileRenderer; 27 | /** 28 | * Called when a user initiates any change to the tree (removing, adding, moving, resizing, etc.) 29 | */ 30 | onChange?: (newNode: MosaicNode | null) => void; 31 | /** 32 | * Called when a user completes a change (fires like above except for the interpolation during resizing) 33 | */ 34 | onRelease?: (newNode: MosaicNode | null) => void; 35 | /** 36 | * Additional classes to affix to the root element 37 | * Default: 'mosaic-blueprint-theme' 38 | */ 39 | className?: string; 40 | /** 41 | * Options that control resizing 42 | * @see: [[ResizeOptions]] 43 | */ 44 | resize?: ResizeOptions; 45 | /** 46 | * View to display when the current value is `null` 47 | * default: Simple NonIdealState view 48 | */ 49 | zeroStateView?: JSX.Element; 50 | /** 51 | * Override the mosaicId passed to `react-dnd` to control how drag and drop works with other components 52 | * Note: does not support updating after instantiation 53 | * default: Random UUID 54 | */ 55 | mosaicId?: string; 56 | /** 57 | * Make it possible to use different versions of Blueprint with `mosaic-blueprint-theme` 58 | * Note: does not support updating after instantiation 59 | * default: 'bp3' 60 | */ 61 | blueprintNamespace?: string; 62 | /** 63 | * Override the react-dnd provider to allow applications to inject an existing drag and drop context 64 | */ 65 | dragAndDropManager?: DragDropManager | undefined; 66 | } 67 | 68 | export interface MosaicControlledProps extends MosaicBaseProps { 69 | /** 70 | * The tree to render 71 | */ 72 | value: MosaicNode | null; 73 | onChange: (newNode: MosaicNode | null) => void; 74 | } 75 | 76 | export interface MosaicUncontrolledProps extends MosaicBaseProps { 77 | /** 78 | * The initial tree to render, can be modified by the user 79 | */ 80 | initialValue: MosaicNode | null; 81 | } 82 | 83 | export type MosaicProps = MosaicControlledProps | MosaicUncontrolledProps; 84 | 85 | function isUncontrolled(props: MosaicProps): props is MosaicUncontrolledProps { 86 | return (props as MosaicUncontrolledProps).initialValue != null; 87 | } 88 | 89 | export interface MosaicState { 90 | currentNode: MosaicNode | null; 91 | lastInitialValue: MosaicNode | null; 92 | mosaicId: string; 93 | } 94 | 95 | export class MosaicWithoutDragDropContext extends React.PureComponent< 96 | MosaicProps, 97 | MosaicState 98 | > { 99 | static defaultProps = { 100 | onChange: () => void 0, 101 | zeroStateView: , 102 | className: 'mosaic-blueprint-theme', 103 | blueprintNamespace: 'bp3', 104 | }; 105 | 106 | static getDerivedStateFromProps( 107 | nextProps: Readonly>, 108 | prevState: MosaicState, 109 | ): Partial> | null { 110 | if (nextProps.mosaicId && prevState.mosaicId !== nextProps.mosaicId && process.env.NODE_ENV !== 'production') { 111 | throw new Error('Mosaic does not support updating the mosaicId after instantiation'); 112 | } 113 | 114 | if (isUncontrolled(nextProps) && nextProps.initialValue !== prevState.lastInitialValue) { 115 | return { 116 | lastInitialValue: nextProps.initialValue, 117 | currentNode: nextProps.initialValue, 118 | }; 119 | } 120 | 121 | return null; 122 | } 123 | 124 | state: MosaicState = { 125 | currentNode: null, 126 | lastInitialValue: null, 127 | mosaicId: this.props.mosaicId ?? uuid(), 128 | }; 129 | 130 | render() { 131 | const { className } = this.props; 132 | 133 | return ( 134 | }> 135 |
136 | {this.renderTree()} 137 | 138 |
139 |
140 | ); 141 | } 142 | 143 | private getRoot(): MosaicNode | null { 144 | if (isUncontrolled(this.props)) { 145 | return this.state.currentNode; 146 | } else { 147 | return this.props.value; 148 | } 149 | } 150 | 151 | private updateRoot = (updates: MosaicUpdate[], suppressOnRelease: boolean = false) => { 152 | const currentNode = this.getRoot() || ({} as MosaicNode); 153 | 154 | this.replaceRoot(updateTree(currentNode, updates), suppressOnRelease); 155 | }; 156 | 157 | private replaceRoot = (currentNode: MosaicNode | null, suppressOnRelease: boolean = false) => { 158 | this.props.onChange!(currentNode); 159 | if (!suppressOnRelease && this.props.onRelease) { 160 | this.props.onRelease(currentNode); 161 | } 162 | 163 | if (isUncontrolled(this.props)) { 164 | this.setState({ currentNode }); 165 | } 166 | }; 167 | 168 | private actions: MosaicRootActions = { 169 | updateTree: this.updateRoot, 170 | remove: (path: MosaicPath) => { 171 | if (path.length === 0) { 172 | this.replaceRoot(null); 173 | } else { 174 | this.updateRoot([createRemoveUpdate(this.getRoot(), path)]); 175 | } 176 | }, 177 | expand: (path: MosaicPath, percentage: number = DEFAULT_EXPAND_PERCENTAGE) => 178 | this.updateRoot([createExpandUpdate(path, percentage)]), 179 | getRoot: () => this.getRoot()!, 180 | hide: (path: MosaicPath) => this.updateRoot([createHideUpdate(path)]), 181 | replaceWith: (path: MosaicPath, newNode: MosaicNode) => 182 | this.updateRoot([ 183 | { 184 | path, 185 | spec: { 186 | $set: newNode, 187 | }, 188 | }, 189 | ]), 190 | }; 191 | 192 | private readonly childContext: MosaicContext = { 193 | mosaicActions: this.actions, 194 | mosaicId: this.state.mosaicId, 195 | blueprintNamespace: this.props.blueprintNamespace!, 196 | }; 197 | 198 | private renderTree() { 199 | const root = this.getRoot(); 200 | this.validateTree(root); 201 | if (root === null || root === undefined) { 202 | return this.props.zeroStateView!; 203 | } else { 204 | const { renderTile, resize } = this.props; 205 | return ; 206 | } 207 | } 208 | 209 | private validateTree(node: MosaicNode | null) { 210 | if (process.env.NODE_ENV !== 'production') { 211 | const duplicates = keys(pickBy(countBy(getLeaves(node)), (n) => n > 1)); 212 | 213 | if (duplicates.length > 0) { 214 | throw new Error( 215 | `Duplicate IDs [${duplicates.join(', ')}] detected. Mosaic does not support leaves with the same ID`, 216 | ); 217 | } 218 | } 219 | } 220 | } 221 | 222 | export class Mosaic extends React.PureComponent> { 223 | render() { 224 | return ( 225 | 230 | {...this.props} /> 231 | 232 | ); 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/MosaicWindow.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import defer from 'lodash/defer'; 3 | import dropRight from 'lodash/dropRight'; 4 | import isEmpty from 'lodash/isEmpty'; 5 | import isEqual from 'lodash/isEqual'; 6 | import values from 'lodash/values'; 7 | import React, { useContext } from 'react'; 8 | import { 9 | ConnectDragPreview, 10 | ConnectDragSource, 11 | ConnectDropTarget, 12 | DropTargetMonitor, 13 | useDrag, 14 | useDrop, 15 | } from 'react-dnd'; 16 | 17 | import { DEFAULT_CONTROLS_WITHOUT_CREATION, DEFAULT_CONTROLS_WITH_CREATION } from './buttons/defaultToolbarControls'; 18 | import { Separator } from './buttons/Separator'; 19 | import { MosaicContext, MosaicWindowContext } from './contextTypes'; 20 | import { MosaicDragItem, MosaicDropData, MosaicDropTargetPosition } from './internalTypes'; 21 | import { MosaicDropTarget } from './MosaicDropTarget'; 22 | import { CreateNode, MosaicBranch, MosaicDirection, MosaicDragType, MosaicKey } from './types'; 23 | import { createDragToUpdates } from './util/mosaicUpdates'; 24 | import { getAndAssertNodeAtPathExists } from './util/mosaicUtilities'; 25 | import { OptionalBlueprint } from './util/OptionalBlueprint'; 26 | 27 | export interface MosaicWindowProps { 28 | title: string; 29 | path: MosaicBranch[]; 30 | children?: React.ReactNode; 31 | className?: string; 32 | toolbarControls?: React.ReactNode; 33 | additionalControls?: React.ReactNode; 34 | additionalControlButtonText?: string; 35 | onAdditionalControlsToggle?: (toggle: boolean) => void; 36 | disableAdditionalControlsOverlay?: boolean; 37 | draggable?: boolean; 38 | createNode?: CreateNode; 39 | renderPreview?: (props: MosaicWindowProps) => JSX.Element; 40 | renderToolbar?: ((props: MosaicWindowProps, draggable: boolean | undefined) => JSX.Element) | null; 41 | onDragStart?: () => void; 42 | onDragEnd?: (type: 'drop' | 'reset') => void; 43 | } 44 | 45 | export interface InternalDragSourceProps { 46 | connectDragSource: ConnectDragSource; 47 | connectDragPreview: ConnectDragPreview; 48 | } 49 | 50 | export interface InternalDropTargetProps { 51 | connectDropTarget: ConnectDropTarget; 52 | isOver: boolean; 53 | draggedMosaicId: string | undefined; 54 | } 55 | 56 | export type InternalMosaicWindowProps = MosaicWindowProps & 57 | InternalDropTargetProps & 58 | InternalDragSourceProps; 59 | 60 | export interface InternalMosaicWindowState { 61 | additionalControlsOpen: boolean; 62 | } 63 | 64 | export class InternalMosaicWindow extends React.Component< 65 | InternalMosaicWindowProps, 66 | InternalMosaicWindowState 67 | > { 68 | static defaultProps: Partial> = { 69 | additionalControlButtonText: 'More', 70 | draggable: true, 71 | renderPreview: ({ title }) => ( 72 |
73 |
74 |
{title}
75 |
76 |
77 |

{title}

78 | 79 |
80 |
81 | ), 82 | renderToolbar: null, 83 | }; 84 | static contextType = MosaicContext; 85 | context!: MosaicContext; 86 | 87 | state: InternalMosaicWindowState = { 88 | additionalControlsOpen: false, 89 | }; 90 | 91 | private rootElement: HTMLElement | null = null; 92 | 93 | render() { 94 | const { 95 | className, 96 | isOver, 97 | renderPreview, 98 | additionalControls, 99 | connectDropTarget, 100 | connectDragPreview, 101 | draggedMosaicId, 102 | disableAdditionalControlsOverlay, 103 | } = this.props; 104 | 105 | return ( 106 | 107 | {connectDropTarget( 108 |
(this.rootElement = element)} 114 | > 115 | {this.renderToolbar()} 116 |
{this.props.children}
117 | {!disableAdditionalControlsOverlay && ( 118 |
{ 121 | this.setAdditionalControlsOpen(false); 122 | }} 123 | /> 124 | )} 125 |
{additionalControls}
126 | {connectDragPreview(renderPreview!(this.props))} 127 |
128 | {values(MosaicDropTargetPosition).map(this.renderDropTarget)} 129 |
130 |
, 131 | )} 132 | 133 | ); 134 | } 135 | 136 | private getToolbarControls() { 137 | const { toolbarControls, createNode } = this.props; 138 | if (toolbarControls) { 139 | return toolbarControls; 140 | } else if (createNode) { 141 | return DEFAULT_CONTROLS_WITH_CREATION; 142 | } else { 143 | return DEFAULT_CONTROLS_WITHOUT_CREATION; 144 | } 145 | } 146 | 147 | private renderToolbar() { 148 | const { title, draggable, additionalControls, additionalControlButtonText, path, renderToolbar } = this.props; 149 | const { additionalControlsOpen } = this.state; 150 | const toolbarControls = this.getToolbarControls(); 151 | const draggableAndNotRoot = draggable && path.length > 0; 152 | const connectIfDraggable = draggableAndNotRoot ? this.props.connectDragSource : (el: React.ReactElement) => el; 153 | 154 | if (renderToolbar) { 155 | const connectedToolbar = connectIfDraggable(renderToolbar(this.props, draggable)) as React.ReactElement; 156 | return ( 157 |
158 | {connectedToolbar} 159 |
160 | ); 161 | } 162 | 163 | const titleDiv = connectIfDraggable( 164 |
165 | {title} 166 |
, 167 | )!; 168 | 169 | const hasAdditionalControls = !isEmpty(additionalControls); 170 | 171 | return ( 172 |
173 | {titleDiv} 174 |
175 | {hasAdditionalControls && ( 176 | 188 | )} 189 | {hasAdditionalControls && } 190 | {toolbarControls} 191 |
192 |
193 | ); 194 | } 195 | 196 | private renderDropTarget = (position: MosaicDropTargetPosition) => { 197 | const { path } = this.props; 198 | 199 | return ; 200 | }; 201 | 202 | private checkCreateNode() { 203 | if (this.props.createNode == null) { 204 | throw new Error('Operation invalid unless `createNode` is defined'); 205 | } 206 | } 207 | 208 | private split = (...args: any[]) => { 209 | this.checkCreateNode(); 210 | const { createNode, path } = this.props; 211 | const { mosaicActions } = this.context; 212 | const root = mosaicActions.getRoot(); 213 | 214 | const direction: MosaicDirection = 215 | this.rootElement!.offsetWidth > this.rootElement!.offsetHeight ? 'row' : 'column'; 216 | 217 | return Promise.resolve(createNode!(...args)).then((second) => 218 | mosaicActions.replaceWith(path, { 219 | direction, 220 | second, 221 | first: getAndAssertNodeAtPathExists(root, path), 222 | }), 223 | ); 224 | }; 225 | 226 | private swap = (...args: any[]) => { 227 | this.checkCreateNode(); 228 | const { mosaicActions } = this.context; 229 | const { createNode, path } = this.props; 230 | return Promise.resolve(createNode!(...args)).then((node) => mosaicActions.replaceWith(path, node)); 231 | }; 232 | 233 | private setAdditionalControlsOpen = (additionalControlsOpenOption: boolean | 'toggle') => { 234 | const additionalControlsOpen = 235 | additionalControlsOpenOption === 'toggle' ? !this.state.additionalControlsOpen : additionalControlsOpenOption; 236 | this.setState({ additionalControlsOpen }); 237 | this.props.onAdditionalControlsToggle?.(additionalControlsOpen); 238 | }; 239 | 240 | private getPath = () => this.props.path; 241 | 242 | private connectDragSource = (connectedElements: React.ReactElement) => { 243 | const { connectDragSource } = this.props; 244 | return connectDragSource(connectedElements); 245 | }; 246 | 247 | private readonly childContext: MosaicWindowContext = { 248 | blueprintNamespace: this.context.blueprintNamespace, 249 | mosaicWindowActions: { 250 | split: this.split, 251 | replaceWithNew: this.swap, 252 | setAdditionalControlsOpen: this.setAdditionalControlsOpen, 253 | getPath: this.getPath, 254 | connectDragSource: this.connectDragSource, 255 | }, 256 | }; 257 | } 258 | 259 | function ConnectedInternalMosaicWindow(props: InternalMosaicWindowProps) { 260 | const { mosaicActions, mosaicId } = useContext(MosaicContext); 261 | 262 | const [, connectDragSource, connectDragPreview] = useDrag({ 263 | type: MosaicDragType.WINDOW, 264 | item: (_monitor): MosaicDragItem | null => { 265 | if (props.onDragStart) { 266 | props.onDragStart(); 267 | } 268 | // TODO: Actually just delete instead of hiding 269 | // The defer is necessary as the element must be present on start for HTML DnD to not cry 270 | const hideTimer = defer(() => mosaicActions.hide(props.path)); 271 | return { 272 | mosaicId, 273 | hideTimer, 274 | }; 275 | }, 276 | end: (item, monitor) => { 277 | const { hideTimer } = item; 278 | // If the hide call hasn't happened yet, cancel it 279 | window.clearTimeout(hideTimer); 280 | 281 | const ownPath = props.path; 282 | const dropResult: MosaicDropData = (monitor.getDropResult() || {}) as MosaicDropData; 283 | const { position, path: destinationPath } = dropResult; 284 | if (position != null && destinationPath != null && !isEqual(destinationPath, ownPath)) { 285 | mosaicActions.updateTree(createDragToUpdates(mosaicActions.getRoot()!, ownPath, destinationPath, position)); 286 | if (props.onDragEnd) { 287 | props.onDragEnd('drop'); 288 | } 289 | } else { 290 | // TODO: restore node from captured state 291 | mosaicActions.updateTree([ 292 | { 293 | path: dropRight(ownPath), 294 | spec: { 295 | splitPercentage: { 296 | $set: undefined, 297 | }, 298 | }, 299 | }, 300 | ]); 301 | if (props.onDragEnd) { 302 | props.onDragEnd('reset'); 303 | } 304 | } 305 | }, 306 | }); 307 | 308 | const [{ isOver, draggedMosaicId }, connectDropTarget] = useDrop({ 309 | accept: MosaicDragType.WINDOW, 310 | collect: (monitor: DropTargetMonitor) => ({ 311 | isOver: monitor.isOver(), 312 | draggedMosaicId: monitor.getItem()?.mosaicId, 313 | }), 314 | }); 315 | return ( 316 | 324 | ); 325 | } 326 | 327 | export class MosaicWindow extends React.PureComponent> { 328 | render() { 329 | return {...(this.props as InternalMosaicWindowProps)} />; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-mosaic 2 | 3 | [![CircleCI](https://circleci.com/gh/nomcopter/react-mosaic/tree/master.svg?style=svg)](https://circleci.com/gh/nomcopter/react-mosaic/tree/master) 4 | [![npm](https://img.shields.io/npm/v/react-mosaic-component.svg)](https://www.npmjs.com/package/react-mosaic-component) 5 | 6 | react-mosaic is a full-featured React Tiling Window Manager meant to give a user complete control over their workspace. 7 | It provides a simple and flexible API to tile arbitrarily complex react components across a user's view. 8 | react-mosaic is written in TypeScript and provides typings but can be used in JavaScript as well. 9 | 10 | The best way to see it is a simple [**Demo**](https://nomcopter.github.io/react-mosaic/). 11 | 12 | #### Screencast 13 | 14 | [![screencast demo](./screencast.gif)](./screencast.gif) 15 | 16 | ## Usage 17 | 18 | The core of react-mosaic's operations revolve around the simple binary tree [specified by `MosaicNode`](./src/types.ts#L12). 19 | [`T`](./src/types.ts#L7) is the type of the leaves of the tree and is a `string` or a `number` that can be resolved to a `JSX.Element` for display. 20 | 21 | ### Installation 22 | 23 | 1. `yarn add react-mosaic-component` 24 | 1. Make sure `react-mosaic-component.css` is included on your page. 25 | 1. Import the `Mosaic` component and use it in your app. 26 | 1. (Optional) Install Blueprint 27 | 28 | ### Blueprint Theme 29 | 30 | Without a theme, Mosaic only loads the styles necessary for it to function - 31 | making it easier for the consumer to style it to match their own app. 32 | 33 | By default, Mosaic renders with the `mosaic-blueprint-theme` class. 34 | This uses the excellent [Blueprint](http://blueprintjs.com/) React UI Toolkit to provide a good starting state. 35 | It is recommended to at least start developing with this theme. 36 | To use it install Blueprint `yarn add @blueprintjs/core @blueprintjs/icons` and add their CSS to your page. 37 | Don't forget to set `blueprintNamespace` in `Mosaic` to the correct value for the version of Blueprint you are using. 38 | 39 | See [blueprint-theme.less](./styles/blueprint-theme.less) for an example of creating a theme. 40 | 41 | #### Blueprint Dark Theme 42 | 43 | Mosaic supports the Blueprint Dark Theme out of the box when rendered with the `mosaic-blueprint-theme bp3-dark` class. 44 | 45 | ### Examples 46 | 47 | #### Simple Tiling 48 | 49 | ##### app.css 50 | 51 | ```css 52 | html, 53 | body, 54 | #app { 55 | height: 100%; 56 | width: 100%; 57 | margin: 0; 58 | } 59 | ``` 60 | 61 | ##### App.tsx 62 | 63 | ```tsx 64 | import { Mosaic } from 'react-mosaic-component'; 65 | 66 | import 'react-mosaic-component/react-mosaic-component.css'; 67 | import '@blueprintjs/core/lib/css/blueprint.css'; 68 | import '@blueprintjs/icons/lib/css/blueprint-icons.css'; 69 | 70 | import './app.css'; 71 | 72 | const ELEMENT_MAP: { [viewId: string]: JSX.Element } = { 73 | a:
Left Window
, 74 | b:
Top Right Window
, 75 | c:
Bottom Right Window
, 76 | }; 77 | 78 | export const app = ( 79 |
80 | 81 | renderTile={(id) => ELEMENT_MAP[id]} 82 | initialValue={{ 83 | direction: 'row', 84 | first: 'a', 85 | second: { 86 | direction: 'column', 87 | first: 'b', 88 | second: 'c', 89 | }, 90 | splitPercentage: 40, 91 | }} 92 | /> 93 |
94 | ); 95 | ``` 96 | 97 | `renderTile` is a stateless lookup function to convert `T` into a displayable `JSX.Element`. 98 | By default `T` is `string` (so to render one element `initialValue="ID"` works). 99 | `T`s must be unique within an instance of `Mosaic`, they are used as keys for [React list management](https://reactjs.org/docs/lists-and-keys.html). 100 | `initialValue` is a [`MosaicNode`](./src/types.ts#L12). 101 | 102 | The user can resize these panes but there is no other advanced functionality. 103 | This example renders a simple tiled interface with one element on the left half, and two stacked elements on the right half. 104 | The user can resize these panes but there is no other advanced functionality. 105 | 106 | #### Drag, Drop, and other advanced functionality with `MosaicWindow` 107 | 108 | `MosaicWindow` is a component that renders a toolbar and controls around its children for a tile as well as providing full featured drag and drop functionality. 109 | 110 | ```tsx 111 | export type ViewId = 'a' | 'b' | 'c' | 'new'; 112 | 113 | const TITLE_MAP: Record = { 114 | a: 'Left Window', 115 | b: 'Top Right Window', 116 | c: 'Bottom Right Window', 117 | new: 'New Window', 118 | }; 119 | 120 | export const app = ( 121 | 122 | renderTile={(id, path) => ( 123 | path={path} createNode={() => 'new'} title={TITLE_MAP[id]}> 124 |

{TITLE_MAP[id]}

125 | 126 | )} 127 | initialValue={{ 128 | direction: 'row', 129 | first: 'a', 130 | second: { 131 | direction: 'column', 132 | first: 'b', 133 | second: 'c', 134 | }, 135 | }} 136 | /> 137 | ); 138 | ``` 139 | 140 | Here `T` is a `ViewId` that can be used to look elements up in `TITLE_MAP`. 141 | This allows for easy view state specification and serialization. 142 | This will render a view that looks very similar to the previous examples, but now each of the windows will have a toolbar with buttons. 143 | These toolbars can be dragged around by a user to rearrange their workspace. 144 | 145 | `MosaicWindow` API docs [here](#mosaicwindow). 146 | 147 | #### Controlled vs. Uncontrolled 148 | 149 | Mosaic views have two modes, similar to `React.DOM` input elements: 150 | 151 | - Controlled, where the consumer manages Mosaic's state through callbacks. 152 | Using this API, the consumer can perform any operation upon the tree to change the the view as desired. 153 | - Uncontrolled, where Mosaic manages all of its state internally. 154 | 155 | See [Controlled Components](https://facebook.github.io/react/docs/forms.html#controlled-components). 156 | 157 | All of the previous examples show use of Mosaic in an Uncontrolled fashion. 158 | 159 | #### Example Application 160 | 161 | See [ExampleApp](demo/ExampleApp.tsx) (the application used in the [Demo](https://nomcopter.github.io/react-mosaic/)) 162 | for a more interesting example that shows the usage of Mosaic as a controlled component and modifications of the tree structure. 163 | 164 | ## API 165 | 166 | #### Mosaic Props 167 | 168 | ```typescript 169 | export interface MosaicBaseProps { 170 | /** 171 | * Lookup function to convert `T` to a displayable `JSX.Element` 172 | */ 173 | renderTile: TileRenderer; 174 | /** 175 | * Called when a user initiates any change to the tree (removing, adding, moving, resizing, etc.) 176 | */ 177 | onChange?: (newNode: MosaicNode | null) => void; 178 | /** 179 | * Called when a user completes a change (fires like above except for the interpolation during resizing) 180 | */ 181 | onRelease?: (newNode: MosaicNode | null) => void; 182 | /** 183 | * Additional classes to affix to the root element 184 | * Default: 'mosaic-blueprint-theme' 185 | */ 186 | className?: string; 187 | /** 188 | * Options that control resizing 189 | * @see: [[ResizeOptions]] 190 | */ 191 | resize?: ResizeOptions; 192 | /** 193 | * View to display when the current value is `null` 194 | * default: Simple NonIdealState view 195 | */ 196 | zeroStateView?: JSX.Element; 197 | /** 198 | * Override the mosaicId passed to `react-dnd` to control how drag and drop works with other components 199 | * Note: does not support updating after instantiation 200 | * default: Random UUID 201 | */ 202 | mosaicId?: string; 203 | /** 204 | * Make it possible to use different versions of Blueprint with `mosaic-blueprint-theme` 205 | * Note: does not support updating after instantiation 206 | * default: 'bp3' 207 | */ 208 | blueprintNamespace?: string; 209 | /** 210 | * Override the react-dnd provider to allow applications to inject an existing drag and drop context 211 | */ 212 | dragAndDropManager?: DragDropManager | undefined; 213 | } 214 | 215 | export interface MosaicControlledProps extends MosaicBaseProps { 216 | /** 217 | * The tree to render 218 | */ 219 | value: MosaicNode | null; 220 | onChange: (newNode: MosaicNode | null) => void; 221 | } 222 | 223 | export interface MosaicUncontrolledProps extends MosaicBaseProps { 224 | /** 225 | * The initial tree to render, can be modified by the user 226 | */ 227 | initialValue: MosaicNode | null; 228 | } 229 | 230 | export type MosaicProps = MosaicControlledProps | MosaicUncontrolledProps; 231 | ``` 232 | 233 | #### `MosaicWindow` 234 | 235 | ```typescript 236 | export interface MosaicWindowProps { 237 | title: string; 238 | /** 239 | * Current path to this window, provided by `renderTile` 240 | */ 241 | path: MosaicBranch[]; 242 | className?: string; 243 | /** 244 | * Controls in the top right of the toolbar 245 | * default: [Replace, Split, Expand, Remove] if createNode is defined and [Expand, Remove] otherwise 246 | */ 247 | toolbarControls?: React.ReactNode; 248 | /** 249 | * Additional controls that will be hidden in a drawer beneath the toolbar. 250 | * default: [] 251 | */ 252 | additionalControls?: React.ReactNode; 253 | /** 254 | * Label for the button that expands the drawer 255 | */ 256 | additionalControlButtonText?: string; 257 | /** 258 | * A callback that triggers when a user toggles the additional controls 259 | */ 260 | onAdditionalControlsToggle?: (toggle: boolean) => void; 261 | /** 262 | * Disables the overlay that blocks interaction with the window when additional controls are open 263 | */ 264 | disableAdditionalControlsOverlay?: boolean; 265 | /** 266 | * Whether or not a user should be able to drag windows around 267 | */ 268 | draggable?: boolean; 269 | /** 270 | * Method called when a new node is required (such as the Split or Replace buttons) 271 | */ 272 | createNode?: CreateNode; 273 | /** 274 | * Optional method to override the displayed preview when a user drags a window 275 | */ 276 | renderPreview?: (props: MosaicWindowProps) => JSX.Element; 277 | /** 278 | * Optional method to override the displayed toolbar 279 | */ 280 | renderToolbar?: ((props: MosaicWindowProps, draggable: boolean | undefined) => JSX.Element) | null; 281 | /** 282 | * Optional listener for when the user begins dragging the window 283 | */ 284 | onDragStart?: () => void; 285 | /** 286 | * Optional listener for when the user finishes dragging a window. 287 | */ 288 | onDragEnd?: (type: 'drop' | 'reset') => void; 289 | } 290 | ``` 291 | 292 | The default controls rendered by `MosaicWindow` can be accessed from [`defaultToolbarControls`](./src/buttons/defaultToolbarControls.tsx) 293 | 294 | ### Advanced API 295 | 296 | The above API is good for most consumers, however Mosaic provides functionality on the [Context](https://facebook.github.io/react/docs/context.html) of its children that make it easier to alter the view state. 297 | All leaves rendered by Mosaic will have the following available on React context. 298 | These are used extensively by `MosaicWindow`. 299 | 300 | ```typescript 301 | /** 302 | * Valid node types 303 | * @see React.Key 304 | */ 305 | export type MosaicKey = string | number; 306 | export type MosaicBranch = 'first' | 'second'; 307 | export type MosaicPath = MosaicBranch[]; 308 | 309 | /** 310 | * Context provided to everything within Mosaic 311 | */ 312 | export interface MosaicContext { 313 | mosaicActions: MosaicRootActions; 314 | mosaicId: string; 315 | } 316 | 317 | export interface MosaicRootActions { 318 | /** 319 | * Increases the size of this node and bubbles up the tree 320 | * @param path Path to node to expand 321 | * @param percentage Every node in the path up to root will be expanded to this percentage 322 | */ 323 | expand: (path: MosaicPath, percentage?: number) => void; 324 | /** 325 | * Remove the node at `path` 326 | * @param path 327 | */ 328 | remove: (path: MosaicPath) => void; 329 | /** 330 | * Hide the node at `path` but keep it in the DOM. Used in Drag and Drop 331 | * @param path 332 | */ 333 | hide: (path: MosaicPath) => void; 334 | /** 335 | * Replace currentNode at `path` with `node` 336 | * @param path 337 | * @param node 338 | */ 339 | replaceWith: (path: MosaicPath, node: MosaicNode) => void; 340 | /** 341 | * Atomically applies all updates to the current tree 342 | * @param updates 343 | * @param suppressOnRelease (default: false) 344 | */ 345 | updateTree: (updates: MosaicUpdate[], suppressOnRelease?: boolean) => void; 346 | /** 347 | * Returns the root of this Mosaic instance 348 | */ 349 | getRoot: () => MosaicNode | null; 350 | } 351 | ``` 352 | 353 | Children (and toolbar elements) within `MosaicWindow` are passed the following additional functions on context. 354 | 355 | ```typescript 356 | export interface MosaicWindowContext extends MosaicContext { 357 | mosaicWindowActions: MosaicWindowActions; 358 | } 359 | 360 | export interface MosaicWindowActions { 361 | /** 362 | * Fails if no `createNode()` is defined 363 | * Creates a new node and splits the current node. 364 | * The current node becomes the `first` and the new node the `second` of the result. 365 | * `direction` is chosen by querying the DOM and splitting along the longer axis 366 | */ 367 | split: () => Promise; 368 | /** 369 | * Fails if no `createNode()` is defined 370 | * Convenience function to call `createNode()` and replace the current node with it. 371 | */ 372 | replaceWithNew: () => Promise; 373 | /** 374 | * Sets the open state for the tray that holds additional controls. 375 | * Pass 'toggle' to invert the current state. 376 | */ 377 | setAdditionalControlsOpen: (open: boolean | 'toggle') => void; 378 | /** 379 | * Returns the path to this window 380 | */ 381 | getPath: () => MosaicPath; 382 | /** 383 | * Enables connecting a different drag source besides the react-mosaic toolbar 384 | */ 385 | connectDragSource: (connectedElements: React.ReactElement) => React.ReactElement; 386 | } 387 | ``` 388 | 389 | To access the functions simply use the [`MosaicContext`](./src/contextTypes.ts#L90) 390 | or [`MosaicWindowContext`](./src/contextTypes.ts#L91) [context consumers](https://reactjs.org/docs/context.html#contextconsumer). 391 | 392 | ### Mutating the Tree 393 | 394 | Utilities are provided for working with the MosaicNode tree in [`mosaicUtilities`](src/util/mosaicUtilities.ts) and 395 | [`mosaicUpdates`](src/util/mosaicUpdates.ts) 396 | 397 | #### MosaicUpdate 398 | 399 | [`MosaicUpdateSpec`](./src/types.ts#L33) is an argument meant to be passed to [`immutability-helper`](https://github.com/kolodny/immutability-helper) 400 | to modify the state at a path. 401 | [`mosaicUpdates`](src/util/mosaicUpdates.ts) has examples. 402 | 403 | ## Upgrade Considerations / Changelog 404 | 405 | See [Releases](https://github.com/nomcopter/react-mosaic/releases) 406 | 407 | ## License 408 | 409 | Copyright 2019 Kevin Verdieck, originally developed at Palantir Technologies, Inc. 410 | 411 | Licensed under the Apache License, Version 2.0 (the "License"); 412 | you may not use this file except in compliance with the License. 413 | You may obtain a copy of the License at 414 | 415 | http://www.apache.org/licenses/LICENSE-2.0 416 | 417 | Unless required by applicable law or agreed to in writing, software 418 | distributed under the License is distributed on an "AS IS" BASIS, 419 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 420 | See the License for the specific language governing permissions and 421 | limitations under the License. 422 | --------------------------------------------------------------------------------