1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-06 09:19:22 +02:00

Desktop: Allow customising application layout

This commit is contained in:
Laurent Cozic
2020-11-13 17:09:28 +00:00
parent 17d835d694
commit 67f0739d3c
222 changed files with 7967 additions and 1810 deletions

View File

@@ -14,7 +14,7 @@ interface Props {
iconName?: string;
level?: ButtonLevel;
className?: string;
onClick: Function;
onClick?: Function;
color?: string;
iconAnimation?: string;
tooltip?: string;
@@ -57,12 +57,14 @@ const StyledButtonPrimary = styled(StyledButtonBase)`
border: none;
background-color: ${(props: any) => props.theme.backgroundColor5};
&:hover {
background-color: ${(props: any) => props.theme.backgroundColorHover5};
}
${(props: any) => props.disabled} {
&:hover {
background-color: ${(props: any) => props.theme.backgroundColorHover5};
}
&:active {
background-color: ${(props: any) => props.theme.backgroundColorActive5};
&:active {
background-color: ${(props: any) => props.theme.backgroundColorActive5};
}
}
${StyledIcon} {
@@ -78,12 +80,14 @@ const StyledButtonSecondary = styled(StyledButtonBase)`
border: 1px solid ${(props: any) => props.theme.borderColor4};
background-color: ${(props: any) => props.theme.backgroundColor4};
&:hover {
background-color: ${(props: any) => props.theme.backgroundColorHover4};
}
${(props: any) => props.disabled} {
&:hover {
background-color: ${(props: any) => props.theme.backgroundColorHover4};
}
&:active {
background-color: ${(props: any) => props.theme.backgroundColorActive4};
&:active {
background-color: ${(props: any) => props.theme.backgroundColorActive4};
}
}
${StyledIcon} {

View File

@@ -1,13 +1,15 @@
import * as React from 'react';
import ResizableLayout, { findItemByKey, LayoutItem, LayoutItemDirection, allDynamicSizes } from '../ResizableLayout/ResizableLayout';
import NoteList from '../NoteList/NoteList';
import ResizableLayout from '../ResizableLayout/ResizableLayout';
import findItemByKey from '../ResizableLayout/utils/findItemByKey';
import { MoveButtonClickEvent } from '../ResizableLayout/MoveButtons';
import { move } from '../ResizableLayout/utils/movements';
import { LayoutItem } from '../ResizableLayout/utils/types';
import NoteEditor from '../NoteEditor/NoteEditor';
import NoteContentPropertiesDialog from '../NoteContentPropertiesDialog';
import ShareNoteDialog from '../ShareNoteDialog';
import NoteListControls from '../NoteListControls/NoteListControls';
import CommandService from '@joplin/lib/services/CommandService';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import SideBar from '../SideBar/SideBar';
import UserWebview from '../../services/plugins/UserWebview';
import UserWebviewDialog from '../../services/plugins/UserWebviewDialog';
@@ -15,20 +17,61 @@ import { ContainerType } from '@joplin/lib/services/plugins/WebviewController';
import { stateUtils } from '@joplin/lib/reducer';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
import { AppState } from '../../app';
import { saveLayout, loadLayout } from '../ResizableLayout/utils/persist';
import Setting from '@joplin/lib/models/Setting';
import produce from 'immer';
import shim from '@joplin/lib/shim';
import bridge from '../../services/bridge';
import time from '@joplin/lib/time';
import styled from 'styled-components';
import { themeStyle } from '@joplin/lib/theme';
import validateLayout from '../ResizableLayout/utils/validateLayout';
import iterateItems from '../ResizableLayout/utils/iterateItems';
import removeItem from '../ResizableLayout/utils/removeItem';
const produce = require('immer').default;
const { connect } = require('react-redux');
const { PromptDialog } = require('../PromptDialog.min.js');
const NotePropertiesDialog = require('../NotePropertiesDialog.min.js');
const Setting = require('@joplin/lib/models/Setting').default;
const shim = require('@joplin/lib/shim').default;
const { themeStyle } = require('@joplin/lib/theme.js');
const bridge = require('electron').remote.require('./bridge').default;
const PluginManager = require('@joplin/lib/services/PluginManager');
const EncryptionService = require('@joplin/lib/services/EncryptionService');
const ipcRenderer = require('electron').ipcRenderer;
const time = require('@joplin/lib/time').default;
const styled = require('styled-components').default;
interface LayerModalState {
visible: boolean;
message: string;
}
interface Props {
plugins: PluginStates;
pluginsLoaded: boolean;
hasNotesBeingSaved: boolean;
dispatch: Function;
mainLayout: LayoutItem;
style: any;
layoutMoveMode: boolean;
editorNoteStatuses: any;
customCss: string;
shouldUpgradeSyncTarget: boolean;
hasDisabledSyncItems: boolean;
hasDisabledEncryptionItems: boolean;
showMissingMasterKeyMessage: boolean;
showNeedUpgradingMasterKeyMessage: boolean;
showShouldReencryptMessage: boolean;
focusedField: string;
themeId: number;
settingEditorCodeView: boolean;
pluginsLegacy: any;
}
interface State {
promptOptions: any;
modalLayer: LayerModalState;
notePropertiesDialogOptions: any;
noteContentPropertiesDialogOptions: any;
shareNoteDialogOptions: any;
}
const StyledUserWebviewDialogContainer = styled.div`
display: flex;
@@ -41,6 +84,15 @@ const StyledUserWebviewDialogContainer = styled.div`
box-sizing: border-box;
`;
const defaultLayout: LayoutItem = {
key: 'root',
children: [
{ key: 'sideBar', width: 250 },
{ key: 'noteList', width: 250 },
{ key: 'editor' },
],
};
const commands = [
require('./commands/editAlarm'),
require('./commands/exportPdf'),
@@ -65,20 +117,21 @@ const commands = [
require('./commands/toggleNoteList'),
require('./commands/toggleSideBar'),
require('./commands/toggleVisiblePanes'),
require('./commands/toggleLayoutMoveMode'),
require('./commands/openNote'),
require('./commands/openFolder'),
require('./commands/openTag'),
];
class MainScreenComponent extends React.Component<any, any> {
class MainScreenComponent extends React.Component<Props, State> {
waitForNotesSavedIID_: any;
isPrinting_: boolean;
styleKey_: string;
styles_: any;
promptOnClose_: Function;
private waitForNotesSavedIID_: any;
private isPrinting_: boolean;
private styleKey_: string;
private styles_: any;
private promptOnClose_: Function;
constructor(props: any) {
constructor(props: Props) {
super(props);
this.state = {
@@ -90,9 +143,10 @@ class MainScreenComponent extends React.Component<any, any> {
notePropertiesDialogOptions: {},
noteContentPropertiesDialogOptions: {},
shareNoteDialogOptions: {},
layout: this.buildLayout(props.plugins),
};
this.updateMainLayout(this.buildLayout(props.plugins));
this.registerCommands();
this.setupAppCloseHandling();
@@ -103,117 +157,72 @@ class MainScreenComponent extends React.Component<any, any> {
this.userWebview_message = this.userWebview_message.bind(this);
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
this.resizableLayout_renderItem = this.resizableLayout_renderItem.bind(this);
this.resizableLayout_moveButtonClick = this.resizableLayout_moveButtonClick.bind(this);
this.window_resize = this.window_resize.bind(this);
this.rowHeight = this.rowHeight.bind(this);
this.layoutModeListenerKeyDown = this.layoutModeListenerKeyDown.bind(this);
window.addEventListener('resize', this.window_resize);
}
buildLayout(plugins: any): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
const theme = themeStyle(this.props.themeId);
const sideBarMinWidth = 200;
const sizes = {
sideBarColumn: {
width: 150,
},
noteListColumn: {
width: 150,
},
pluginColumn: {
width: 150,
},
...Setting.value('ui.layout'),
};
for (const k in sizes) {
if (sizes[k].width < sideBarMinWidth) sizes[k].width = sideBarMinWidth;
}
const pluginColumnChildren: LayoutItem[] = [];
private updateLayoutPluginViews(layout: LayoutItem, plugins: PluginStates) {
const infos = pluginUtils.viewInfosByType(plugins, 'webview');
for (const info of infos) {
if (info.view.containerType !== ContainerType.Panel) continue;
let newLayout = produce(layout, (draftLayout: LayoutItem) => {
for (const info of infos) {
if (info.view.containerType !== ContainerType.Panel) continue;
// For now it's assumed all views go in the "pluginColumn" so they are
// resizable vertically. But horizontally they stretch 100%
const viewId = info.view.id;
const viewId = info.view.id;
const existingItem = findItemByKey(draftLayout, viewId);
const size = {
...(sizes[viewId] ? sizes[viewId] : null),
width: '100%',
};
if (!existingItem) {
draftLayout.children.push({
key: viewId,
context: {
pluginId: info.plugin.id,
},
});
}
}
});
pluginColumnChildren.push({
key: viewId,
resizableBottom: true,
context: {
plugin: info.plugin,
control: info.view,
},
...size,
});
// Remove layout items that belong to plugins that are no longer
// active.
const pluginIds = Object.keys(plugins);
const itemsToRemove: string[] = [];
iterateItems(newLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => {
if (item.context && item.context.pluginId && !pluginIds.includes(item.context.pluginId)) {
itemsToRemove.push(item.key);
}
return true;
});
for (const itemKey of itemsToRemove) {
newLayout = removeItem(newLayout, itemKey);
}
return {
key: 'root',
direction: LayoutItemDirection.Row,
width: rootLayoutSize.width,
height: rootLayoutSize.height,
children: [
{
key: 'sideBarColumn',
direction: LayoutItemDirection.Column,
resizableRight: true,
width: sizes.sideBarColumn.width,
visible: Setting.value('sidebarVisibility'),
minWidth: sideBarMinWidth,
children: [
{
key: 'sideBar',
},
],
},
{
key: 'noteListColumn',
direction: LayoutItemDirection.Column,
resizableRight: true,
width: sizes.noteListColumn.width,
visible: Setting.value('noteListVisibility'),
minWidth: sideBarMinWidth,
children: [
{
height: theme.topRowHeight,
key: 'noteListControls',
},
{
key: 'noteList',
},
],
},
{
key: 'pluginColumn',
direction: LayoutItemDirection.Column,
resizableRight: true,
width: sizes.pluginColumn.width,
visible: !!pluginColumnChildren.length,
minWidth: sideBarMinWidth,
children: pluginColumnChildren,
},
{
key: 'editorColumn',
direction: LayoutItemDirection.Column,
children: [
{
key: 'editor',
},
],
},
],
};
return newLayout !== layout ? validateLayout(newLayout) : layout;
}
private buildLayout(plugins: PluginStates): LayoutItem {
const rootLayoutSize = this.rootLayoutSize();
const userLayout = Setting.value('ui.layout');
let output = null;
try {
output = loadLayout(userLayout, defaultLayout, rootLayoutSize);
if (!findItemByKey(output, 'sideBar') || !findItemByKey(output, 'noteList') || !findItemByKey(output, 'editor')) {
throw new Error('"sideBar", "noteList" and "editor" must be present in the layout');
}
} catch (error) {
console.warn('Could not load layout - restoring default layout:', error);
console.warn('Layout was:', userLayout);
output = loadLayout(null, defaultLayout, rootLayoutSize);
}
return this.updateLayoutPluginViews(output, plugins);
}
window_resize() {
@@ -263,25 +272,22 @@ class MainScreenComponent extends React.Component<any, any> {
this.setState({ shareNoteDialogOptions: {} });
}
updateMainLayout(layout: LayoutItem) {
this.props.dispatch({
type: 'MAIN_LAYOUT_SET',
value: layout,
});
}
updateRootLayoutSize() {
this.setState({ layout: produce(this.state.layout, (draft: any) => {
this.updateMainLayout(produce(this.props.mainLayout, (draft: any) => {
const s = this.rootLayoutSize();
draft.width = s.width;
draft.height = s.height;
}) });
}));
}
componentDidUpdate(prevProps: any, prevState: any) {
if (this.props.noteListVisibility !== prevProps.noteListVisibility || this.props.sidebarVisibility !== prevProps.sidebarVisibility) {
this.setState({ layout: produce(this.state.layout, (draft: any) => {
const noteListColumn = findItemByKey(draft, 'noteListColumn');
noteListColumn.visible = this.props.noteListVisibility;
const sideBarColumn = findItemByKey(draft, 'sideBarColumn');
sideBarColumn.visible = this.props.sidebarVisibility;
}) });
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps.style.width !== this.props.style.width ||
prevProps.style.height !== this.props.style.height ||
this.messageBoxVisible(prevProps) !== this.messageBoxVisible(this.props)
@@ -290,7 +296,8 @@ class MainScreenComponent extends React.Component<any, any> {
}
if (prevProps.plugins !== this.props.plugins) {
this.setState({ layout: this.buildLayout(this.props.plugins) });
this.updateMainLayout(this.updateLayoutPluginViews(this.props.mainLayout, this.props.plugins));
// this.setState({ layout: this.buildLayout(this.props.plugins) });
}
if (this.state.notePropertiesDialogOptions !== prevState.notePropertiesDialogOptions) {
@@ -313,28 +320,28 @@ class MainScreenComponent extends React.Component<any, any> {
name: 'shareNote',
});
}
if (this.props.mainLayout !== prevProps.mainLayout) {
const toSave = saveLayout(this.props.mainLayout);
Setting.setValue('ui.layout', toSave);
}
}
layoutModeListenerKeyDown(event: any) {
if (event.key !== 'Escape') return;
if (!this.props.layoutMoveMode) return;
CommandService.instance().execute('toggleLayoutMoveMode');
}
componentDidMount() {
this.updateRootLayoutSize();
window.addEventListener('keydown', this.layoutModeListenerKeyDown);
}
componentWillUnmount() {
this.unregisterCommands();
window.removeEventListener('resize', this.window_resize);
}
toggleSideBar() {
this.props.dispatch({
type: 'SIDEBAR_VISIBILITY_TOGGLE',
});
}
toggleNoteList() {
this.props.dispatch({
type: 'NOTELIST_VISIBILITY_TOGGLE',
});
window.removeEventListener('keydown', this.layoutModeListenerKeyDown);
}
async waitForNoteToSaved(noteId: string) {
@@ -399,8 +406,8 @@ class MainScreenComponent extends React.Component<any, any> {
return 50;
}
styles(themeId: number, width: number, height: number, messageBoxVisible: boolean, isSidebarVisible: any, isNoteListVisible: any) {
const styleKey = [themeId, width, height, messageBoxVisible, +isSidebarVisible, +isNoteListVisible].join('_');
styles(themeId: number, width: number, height: number, messageBoxVisible: boolean) {
const styleKey = [themeId, width, height, messageBoxVisible].join('_');
if (styleKey === this.styleKey_) return this.styles_;
const theme = themeStyle(themeId);
@@ -561,30 +568,57 @@ class MainScreenComponent extends React.Component<any, any> {
}
resizableLayout_resize(event: any) {
this.setState({ layout: event.layout });
Setting.setValue('ui.layout', allDynamicSizes(event.layout));
this.updateMainLayout(event.layout);
}
resizableLayout_moveButtonClick(event: MoveButtonClickEvent) {
const newLayout = move(this.props.mainLayout, event.itemKey, event.direction);
this.updateMainLayout(newLayout);
}
resizableLayout_renderItem(key: string, event: any) {
const eventEmitter = event.eventEmitter;
if (key === 'sideBar') {
return <SideBar key={key} />;
} else if (key === 'noteList') {
return <NoteList key={key} resizableLayoutEventEmitter={eventEmitter} size={event.size} visible={event.visible}/>;
} else if (key === 'editor') {
const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
} else if (key === 'noteListControls') {
return <NoteListControls key={key} showNewNoteButtons={this.props.focusedField !== 'globalSearch'} />;
} else if (key.indexOf('plugin-view') === 0) {
const { control, plugin } = event.item.context;
const components: any = {
sideBar: () => {
return <SideBar key={key} />;
},
noteList: () => {
return <NoteListWrapper
key={key}
resizableLayoutEventEmitter={eventEmitter}
visible={event.visible}
focusedField={this.props.focusedField}
size={event.size}
themeId={this.props.themeId}
/>;
},
editor: () => {
const bodyEditor = this.props.settingEditorCodeView ? 'CodeMirror' : 'TinyMCE';
return <NoteEditor key={key} bodyEditor={bodyEditor} />;
},
};
if (components[key]) return components[key]();
if (key.indexOf('plugin-view') === 0) {
const viewInfo = pluginUtils.viewInfoByViewId(this.props.plugins, event.item.key);
if (!viewInfo) {
console.warn(`Could not find plugin associated with view: ${event.item.key}`);
return null;
}
const { view, plugin } = viewInfo;
return <UserWebview
key={control.id}
viewId={control.id}
key={view.id}
viewId={view.id}
themeId={this.props.themeId}
html={control.html}
scripts={control.scripts}
html={view.html}
scripts={view.scripts}
pluginId={plugin.id}
onMessage={this.userWebview_message}
borderBottom={true}
@@ -635,9 +669,7 @@ class MainScreenComponent extends React.Component<any, any> {
this.props.style
);
const promptOptions = this.state.promptOptions;
const sidebarVisibility = this.props.sidebarVisibility;
const noteListVisibility = this.props.noteListVisibility;
const styles = this.styles(this.props.themeId, style.width, style.height, this.messageBoxVisible(), sidebarVisibility, noteListVisibility);
const styles = this.styles(this.props.themeId, style.width, style.height, this.messageBoxVisible());
if (!this.promptOnClose_) {
this.promptOnClose_ = (answer: any, buttonType: any) => {
@@ -656,6 +688,18 @@ class MainScreenComponent extends React.Component<any, any> {
const noteContentPropertiesDialogOptions = this.state.noteContentPropertiesDialogOptions;
const shareNoteDialogOptions = this.state.shareNoteDialogOptions;
const layoutComp = this.props.mainLayout ? (
<ResizableLayout
height={styles.rowHeight}
layout={this.props.mainLayout}
onResize={this.resizableLayout_resize}
onMoveButtonClick={this.resizableLayout_moveButtonClick}
renderItem={this.resizableLayout_renderItem}
moveMode={this.props.layoutMoveMode}
moveModeMessage={_('Use the arrows to move the layout items. Press "Escape" to exit.')}
/>
) : null;
return (
<div style={style}>
<div style={modalLayerStyle}>{this.state.modalLayer.message}</div>
@@ -667,25 +711,17 @@ class MainScreenComponent extends React.Component<any, any> {
<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />
{messageComp}
<ResizableLayout
width={this.state.width}
height={styles.rowHeight}
layout={this.state.layout}
onResize={this.resizableLayout_resize}
renderItem={this.resizableLayout_renderItem}
/>
{layoutComp}
{pluginDialog}
</div>
);
}
}
const mapStateToProps = (state: any) => {
const mapStateToProps = (state: AppState) => {
return {
themeId: state.settings.theme,
settingEditorCodeView: state.settings['editor.codeView'],
sidebarVisibility: state.sidebarVisibility,
noteListVisibility: state.noteListVisibility,
folders: state.folders,
notes: state.notes,
hasDisabledSyncItems: state.hasDisabledSyncItems,
@@ -703,6 +739,8 @@ const mapStateToProps = (state: any) => {
editorNoteStatuses: state.editorNoteStatuses,
hasNotesBeingSaved: stateUtils.hasNotesBeingSaved(state),
focusedField: state.focusedField,
layoutMoveMode: state.layoutMoveMode,
mainLayout: state.mainLayout,
};
};

View File

@@ -0,0 +1,20 @@
import { CommandDeclaration, CommandRuntime, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { AppState } from '../../../app';
export const declaration: CommandDeclaration = {
name: 'toggleLayoutMoveMode',
label: () => _('Change application layout'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, value: boolean = null) => {
const newValue = value !== null ? value : !(context.state as AppState).layoutMoveMode;
context.dispatch({
type: 'LAYOUT_MOVE_MODE_SET',
value: newValue,
});
},
};
};

View File

@@ -1,5 +1,8 @@
import { CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import setLayoutItemProps from '../../ResizableLayout/utils/setLayoutItemProps';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app';
export const declaration: CommandDeclaration = {
name: 'toggleNoteList',
@@ -7,11 +10,18 @@ export const declaration: CommandDeclaration = {
iconName: 'fas fa-align-justify',
};
export const runtime = (comp: any): CommandRuntime => {
export const runtime = (): CommandRuntime => {
return {
execute: async () => {
comp.props.dispatch({
type: 'NOTELIST_VISIBILITY_TOGGLE',
execute: async (context: CommandContext) => {
const layout = (context.state as AppState).mainLayout;
const newLayout = setLayoutItemProps(layout, 'noteList', {
visible: !layoutItemProp(layout, 'noteList', 'visible'),
});
context.dispatch({
type: 'MAIN_LAYOUT_SET',
value: newLayout,
});
},
};

View File

@@ -1,5 +1,8 @@
import { CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { CommandContext, CommandDeclaration, CommandRuntime } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import setLayoutItemProps from '../../ResizableLayout/utils/setLayoutItemProps';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app';
export const declaration: CommandDeclaration = {
name: 'toggleSideBar',
@@ -7,11 +10,18 @@ export const declaration: CommandDeclaration = {
iconName: 'fas fa-bars',
};
export const runtime = (comp: any): CommandRuntime => {
export const runtime = (): CommandRuntime => {
return {
execute: async () => {
comp.props.dispatch({
type: 'SIDEBAR_VISIBILITY_TOGGLE',
execute: async (context: CommandContext) => {
const layout = (context.state as AppState).mainLayout;
const newLayout = setLayoutItemProps(layout, 'sideBar', {
visible: !layoutItemProp(layout, 'sideBar', 'visible'),
});
context.dispatch({
type: 'MAIN_LAYOUT_SET',
value: newLayout,
});
},
};

View File

@@ -13,9 +13,9 @@ import { Module } from '@joplin/lib/services/interop/types';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import { MenuItem, MenuItemLocation } from '@joplin/lib/services/plugins/api/types';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerService';
import menuCommandNames from './menuCommandNames';
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
const { connect } = require('react-redux');
const { reg } = require('@joplin/lib/registry.js');
@@ -519,6 +519,8 @@ function useMenu(props: Props) {
view: {
label: _('&View'),
submenu: [
menuItemDic.toggleLayoutMoveMode,
separator(),
menuItemDic.toggleSideBar,
menuItemDic.toggleNoteList,
menuItemDic.toggleVisiblePanes,

View File

@@ -5,7 +5,7 @@ import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import { connect } from 'react-redux';
import { AppState } from '../../../../app';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import stateToWhenClauseContext from '../../../../services/commands/stateToWhenClauseContext';
const { buildStyle } = require('@joplin/lib/theme');
interface ToolbarProps {

View File

@@ -23,12 +23,12 @@ import eventManager from '@joplin/lib/eventManager';
import { AppState } from '../../app';
import ToolbarButtonUtils from '@joplin/lib/services/commands/ToolbarButtonUtils';
import { _ } from '@joplin/lib/locale';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import TagList from '../TagList';
import NoteTitleBar from './NoteTitle/NoteTitleBar';
import markupLanguageUtils from '@joplin/lib/markupLanguageUtils';
import usePrevious from '../hooks/usePrevious';
import Setting from '@joplin/lib/models/Setting';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
const { themeStyle } = require('@joplin/lib/theme');
const { substrWithEllipsis } = require('@joplin/lib/string-utils');

View File

@@ -8,10 +8,11 @@ const styled = require('styled-components').default;
interface Props {
showNewNoteButtons: boolean;
height: number;
}
const StyledRoot = styled.div`
width: 100%;
height: ${(props: any) => props.height}px;
display: flex;
flex-direction: row;
padding: ${(props: any) => props.theme.mainPadding}px;
@@ -68,7 +69,7 @@ export default function NoteListControls(props: Props) {
}
return (
<StyledRoot>
<StyledRoot height={props.height}>
<SearchBar inputRef={searchBarRef}/>
{renderNewNoteButtons()}
</StyledRoot>

View File

@@ -0,0 +1,39 @@
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useMemo } from 'react';
import NoteList from '../NoteList/NoteList';
import NoteListControls from '../NoteListControls/NoteListControls';
import { Size } from '../ResizableLayout/utils/types';
import styled from 'styled-components';
interface Props {
resizableLayoutEventEmitter: any;
size: Size;
visible: boolean;
focusedField: string;
themeId: number;
}
const StyledRoot = styled.div`
display: flex;
flex-direction: column;
`;
export default function NoteListWrapper(props: Props) {
const theme = themeStyle(props.themeId);
const controlHeight = theme.topRowHeight;
const noteListSize = useMemo(() => {
return {
width: props.size.width,
height: props.size.height - controlHeight,
};
}, [props.size, controlHeight]);
return (
<StyledRoot>
<NoteListControls showNewNoteButtons={props.focusedField !== 'globalSearch'} height={controlHeight} />
<NoteList resizableLayoutEventEmitter={props.resizableLayoutEventEmitter} size={noteListSize} visible={props.visible}/>
</StyledRoot>
);
}

View File

@@ -3,7 +3,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import ToolbarBase from '../ToolbarBase';
import { utils as pluginUtils } from '@joplin/lib/services/plugins/reducer';
import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils';
import stateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
const { connect } = require('react-redux');
const { buildStyle } = require('@joplin/lib/theme');

View File

@@ -0,0 +1,80 @@
import * as React from 'react';
import { useCallback } from 'react';
import Button, { ButtonLevel } from '../Button/Button';
import { MoveDirection } from './utils/movements';
import styled from 'styled-components';
const StyledRoot = styled.div`
display: flex;
flex-direction: column;
padding: 5px;
background-color: ${props => props.theme.backgroundColor};
border-radius: 5px;
`;
const ButtonRow = styled.div`
display: flex;
flex-direction: row;
justify-content: center;
`;
const EmptyButton = styled(Button)`
visibility: hidden;
`;
const ArrowButton = styled(Button)`
opacity: ${props => props.disabled ? 0.2 : 1};
`;
export interface MoveButtonClickEvent {
direction: MoveDirection;
itemKey: string;
}
interface Props {
onClick(event: MoveButtonClickEvent): void;
itemKey: string;
canMoveLeft: boolean;
canMoveRight: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
}
export default function MoveButtons(props: Props) {
const onButtonClick = useCallback((direction: MoveDirection) => {
props.onClick({ direction, itemKey: props.itemKey });
}, [props.onClick, props.itemKey]);
function canMove(dir: MoveDirection) {
if (dir === MoveDirection.Up) return props.canMoveUp;
if (dir === MoveDirection.Down) return props.canMoveDown;
if (dir === MoveDirection.Left) return props.canMoveLeft;
if (dir === MoveDirection.Right) return props.canMoveRight;
throw new Error('Unreachable');
}
function renderButton(dir: MoveDirection) {
return <ArrowButton
disabled={!canMove(dir)}
level={ButtonLevel.Primary}
iconName={`fas fa-arrow-${dir}`}
onClick={() => onButtonClick(dir)}
/>;
}
return (
<StyledRoot>
<ButtonRow>
{renderButton(MoveDirection.Up)}
</ButtonRow>
<ButtonRow>
{renderButton(MoveDirection.Left)}
<EmptyButton iconName="fas fa-arrow-down" disabled={true}/>
{renderButton(MoveDirection.Right)}
</ButtonRow>
<ButtonRow>
{renderButton(MoveDirection.Down)}
</ButtonRow>
</StyledRoot>
);
}

View File

@@ -1,36 +1,18 @@
import * as React from 'react';
import { useRef, useState } from 'react';
import produce from 'immer';
import useWindowResizeEvent from './hooks/useWindowResizeEvent';
import useLayoutItemSizes, { LayoutItemSizes, itemSize } from './hooks/useLayoutItemSizes';
const { Resizable } = require('re-resizable');
import { useRef, useState, useEffect } from 'react';
import useWindowResizeEvent from './utils/useWindowResizeEvent';
import setLayoutItemProps from './utils/setLayoutItemProps';
import useLayoutItemSizes, { LayoutItemSizes, itemSize } from './utils/useLayoutItemSizes';
import validateLayout from './utils/validateLayout';
import { Size, LayoutItem } from './utils/types';
import { canMove, MoveDirection } from './utils/movements';
import MoveButtons, { MoveButtonClickEvent } from './MoveButtons';
import { StyledWrapperRoot, StyledMoveOverlay, MoveModeRootWrapper, MoveModeRootMessage } from './utils/style';
import { Resizable } from 're-resizable';
const EventEmitter = require('events');
export const dragBarThickness = 5;
export enum LayoutItemDirection {
Row = 'row',
Column = 'column',
}
export interface Size {
width: number;
height: number;
}
export interface LayoutItem {
key: string;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
children?: LayoutItem[];
direction?: LayoutItemDirection;
resizableRight?: boolean;
resizableBottom?: boolean;
visible?: boolean;
context?: any;
}
const itemMinWidth = 20;
const itemMinHeight = 20;
interface onResizeEvent {
layout: LayoutItem;
@@ -42,78 +24,24 @@ interface Props {
width?: number;
height?: number;
renderItem: Function;
onMoveButtonClick(event: MoveButtonClickEvent): void;
moveMode: boolean;
moveModeMessage: string;
}
export function allDynamicSizes(layout: LayoutItem): any {
const output: any = {};
function recurseProcess(item: LayoutItem) {
if (item.resizableBottom || item.resizableRight) {
if ('width' in item || 'height' in item) {
const size: any = {};
if ('width' in item) size.width = item.width;
if ('height' in item) size.height = item.height;
output[item.key] = size;
}
}
if (item.children) {
for (const child of item.children) {
recurseProcess(child);
}
}
}
recurseProcess(layout);
return output;
function itemVisible(item: LayoutItem, moveMode: boolean) {
if (moveMode) return true;
if (item.children && !item.children.length) return false;
return item.visible !== false;
}
export function findItemByKey(layout: LayoutItem, key: string): LayoutItem {
function recurseFind(item: LayoutItem): LayoutItem {
if (item.key === key) return item;
if (item.children) {
for (const child of item.children) {
const found = recurseFind(child);
if (found) return found;
}
}
return null;
}
const output = recurseFind(layout);
if (!output) throw new Error(`Invalid item key: ${key}`);
return output;
}
function updateLayoutItem(layout: LayoutItem, key: string, props: any) {
return produce(layout, (draftState: LayoutItem) => {
function recurseFind(item: LayoutItem) {
if (item.key === key) {
for (const n in props) {
(item as any)[n] = props[n];
}
} else {
if (item.children) {
for (const child of item.children) {
recurseFind(child);
}
}
}
}
recurseFind(draftState);
});
}
function renderContainer(item: LayoutItem, sizes: LayoutItemSizes, onResizeStart: Function, onResize: Function, onResizeStop: Function, children: any[], isLastChild: boolean): any {
function renderContainer(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, onResizeStart: Function, onResize: Function, onResizeStop: Function, children: any[], isLastChild: boolean, moveMode: boolean): any {
const style: any = {
display: item.visible !== false ? 'flex' : 'none',
display: itemVisible(item, moveMode) ? 'flex' : 'none',
flexDirection: item.direction,
};
const size: Size = itemSize(item, sizes);
const size: Size = itemSize(item, parent, sizes, true);
const className = `resizableLayoutItem rli-${item.key}`;
if (item.resizableRight || item.resizableBottom) {
@@ -128,21 +56,18 @@ function renderContainer(item: LayoutItem, sizes: LayoutItemSizes, onResizeStart
topLeft: false,
};
if (item.resizableRight) style.paddingRight = dragBarThickness;
if (item.resizableBottom) style.paddingBottom = dragBarThickness;
return (
<Resizable
key={item.key}
className={className}
style={style}
size={size}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
onResizeStart={onResizeStart as any}
onResize={onResize as any}
onResizeStop={onResizeStop as any}
enable={enable}
minWidth={item.minWidth}
minHeight={item.minHeight}
minWidth={'minWidth' in item ? item.minWidth : itemMinWidth}
minHeight={'minHeight' in item ? item.minHeight : itemMinHeight}
>
{children}
</Resizable>
@@ -161,8 +86,29 @@ function ResizableLayout(props: Props) {
const [resizedItem, setResizedItem] = useState<any>(null);
function renderLayoutItem(item: LayoutItem, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean): any {
function renderItemWrapper(comp: any, item: LayoutItem, parent: LayoutItem | null, size: Size, moveMode: boolean) {
const moveOverlay = moveMode ? (
<StyledMoveOverlay>
<MoveButtons
itemKey={item.key}
onClick={props.onMoveButtonClick}
canMoveLeft={canMove(MoveDirection.Left, item, parent)}
canMoveRight={canMove(MoveDirection.Right, item, parent)}
canMoveUp={canMove(MoveDirection.Up, item, parent)}
canMoveDown={canMove(MoveDirection.Down, item, parent)}
/>
</StyledMoveOverlay>
) : null;
return (
<StyledWrapperRoot key={item.key} size={size}>
{moveOverlay}
{comp}
</StyledWrapperRoot>
);
}
function renderLayoutItem(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isVisible: boolean, isLastChild: boolean): any {
function onResizeStart() {
setResizedItem({
key: item.key,
@@ -171,45 +117,79 @@ function ResizableLayout(props: Props) {
});
}
function onResize(_event: any, _direction: any, _refToElement: HTMLDivElement, delta: any) {
const newLayout = updateLayoutItem(props.layout, item.key, {
width: resizedItem.initialWidth + delta.width,
height: resizedItem.initialHeight + delta.height,
});
function onResize(_event: any, direction: string, _refToElement: any, delta: any) {
const newWidth = Math.max(itemMinWidth, resizedItem.initialWidth + delta.width);
const newHeight = Math.max(itemMinHeight, resizedItem.initialHeight + delta.height);
const newSize: any = {};
if (item.width) newSize.width = item.width;
if (item.height) newSize.height = item.height;
if (direction === 'bottom') {
newSize.height = newHeight;
} else {
newSize.width = newWidth;
}
const newLayout = setLayoutItemProps(props.layout, item.key, newSize);
props.onResize({ layout: newLayout });
eventEmitter.current.emit('resize');
}
function onResizeStop(_event: any, _direction: any, _refToElement: HTMLDivElement, delta: any) {
function onResizeStop(_event: any, _direction: any, _refToElement: any, delta: any) {
onResize(_event, _direction, _refToElement, delta);
setResizedItem(null);
}
if (!item.children) {
const size = itemSize(item, parent, sizes, false);
const comp = props.renderItem(item.key, {
item: item,
eventEmitter: eventEmitter.current,
size: sizes[item.key],
size: size,
visible: isVisible,
});
return renderContainer(item, sizes, onResizeStart, onResize, onResizeStop, [comp], isLastChild);
const wrapper = renderItemWrapper(comp, item, parent, size, props.moveMode);
return renderContainer(item, parent, sizes, onResizeStart, onResize, onResizeStop, [wrapper], isLastChild, props.moveMode);
} else {
const childrenComponents = [];
for (let i = 0; i < item.children.length; i++) {
const child = item.children[i];
childrenComponents.push(renderLayoutItem(child, sizes, isVisible && child.visible !== false, i === item.children.length - 1));
childrenComponents.push(renderLayoutItem(child, item, sizes, isVisible && itemVisible(child, props.moveMode), i === item.children.length - 1));
}
return renderContainer(item, sizes, onResizeStart, onResize, onResizeStop, childrenComponents, isLastChild);
return renderContainer(item, parent, sizes, onResizeStart, onResize, onResizeStop, childrenComponents, isLastChild, props.moveMode);
}
}
useWindowResizeEvent(eventEmitter);
const sizes = useLayoutItemSizes(props.layout);
useEffect(() => {
validateLayout(props.layout);
}, [props.layout]);
return renderLayoutItem(props.layout, sizes, props.layout.visible !== false, true);
useWindowResizeEvent(eventEmitter);
const sizes = useLayoutItemSizes(props.layout, props.moveMode);
function renderMoveModeBox(rootComp: any) {
return (
<MoveModeRootWrapper>
<MoveModeRootMessage>{props.moveModeMessage}</MoveModeRootMessage>
{rootComp}
</MoveModeRootWrapper>
);
}
const rootComp = renderLayoutItem(props.layout, null, sizes, itemVisible(props.layout, props.moveMode), true);
if (props.moveMode) {
return renderMoveModeBox(rootComp);
} else {
return rootComp;
}
}
export default ResizableLayout;

View File

@@ -1,83 +0,0 @@
import { useMemo } from 'react';
import { LayoutItem, Size, dragBarThickness } from '../ResizableLayout';
export interface LayoutItemSizes {
[key: string]: Size;
}
export function itemSize(item: LayoutItem, sizes: LayoutItemSizes): Size {
return {
width: 'width' in item ? item.width : sizes[item.key].width,
height: 'height' in item ? item.height : sizes[item.key].height,
};
}
function calculateChildrenSizes(item: LayoutItem, sizes: LayoutItemSizes): LayoutItemSizes {
if (!item.children) return sizes;
const parentSize = itemSize(item, sizes);
const remainingSize: Size = {
width: parentSize.width,
height: parentSize.height,
};
const noWidthChildren: LayoutItem[] = [];
const noHeightChildren: LayoutItem[] = [];
for (const child of item.children) {
let w = 'width' in child ? child.width : null;
let h = 'height' in child ? child.height : null;
if (child.visible === false) {
w = 0;
h = 0;
}
if (item.resizableRight) w -= dragBarThickness;
if (item.resizableBottom) h -= dragBarThickness;
sizes[child.key] = { width: w, height: h };
if (w !== null) remainingSize.width -= w;
if (h !== null) remainingSize.height -= h;
if (w === null) noWidthChildren.push(child);
if (h === null) noHeightChildren.push(child);
}
if (noWidthChildren.length) {
const w = item.direction === 'row' ? remainingSize.width / noWidthChildren.length : parentSize.width;
for (const child of noWidthChildren) {
sizes[child.key].width = w;
}
}
if (noHeightChildren.length) {
const h = item.direction === 'column' ? remainingSize.height / noHeightChildren.length : parentSize.height;
for (const child of noHeightChildren) {
sizes[child.key].height = h;
}
}
for (const child of item.children) {
const childrenSizes = calculateChildrenSizes(child, sizes);
sizes = { ...sizes, ...childrenSizes };
}
return sizes;
}
export default function useLayoutItemSizes(layout: LayoutItem) {
return useMemo(() => {
let sizes: LayoutItemSizes = {};
if (!('width' in layout) || !('height' in layout)) throw new Error('width and height are required on layout root');
sizes[layout.key] = {
width: layout.width,
height: layout.height,
};
sizes = calculateChildrenSizes(layout, sizes);
return sizes;
}, [layout]);
}

View File

@@ -0,0 +1,23 @@
import { LayoutItem } from './types';
export default function findItemByKey(layout: LayoutItem, key: string): LayoutItem {
if (!layout) throw new Error('Layout cannot be null');
function recurseFind(item: LayoutItem): LayoutItem {
if (item.key === key) return item;
if (item.children) {
for (const child of item.children) {
const found = recurseFind(child);
if (found) return found;
}
}
return null;
}
return recurseFind(layout);
// const output = recurseFind(layout);
// if (!output) throw new Error(`Could not find item "${key}"`);
// return output;
}

View File

@@ -0,0 +1,5 @@
import { tempContainerPrefix } from './types';
export default function(itemKey: string): boolean {
return itemKey.indexOf(tempContainerPrefix) === 0;
}

View File

@@ -0,0 +1,21 @@
import { LayoutItem } from './types';
type ItemItemCallback = (itemIndex: number, item: LayoutItem, parent: LayoutItem)=> boolean;
export default function iterateItems(layout: LayoutItem, callback: ItemItemCallback) {
const result = callback(0, layout, null);
if (result === false) return;
function recurseFind(item: LayoutItem, callback: Function): boolean {
if (item.children) {
for (let childIndex = 0; childIndex < item.children.length; childIndex++) {
const child = item.children[childIndex];
if (callback(childIndex, child, item) === false) return false;
if (recurseFind(child, callback) === false) return false;
}
}
return true;
}
if (recurseFind(layout, callback) === false) return;
}

View File

@@ -0,0 +1,8 @@
import findItemByKey from './findItemByKey';
import { LayoutItem } from './types';
export default function layoutItemProp(layout: LayoutItem, key: string, propName: string) {
const item = findItemByKey(layout, key);
if (!item) throw new Error(`Could not find layout item: ${key}`);
return (item as any)[propName];
}

View File

@@ -0,0 +1,228 @@
import { LayoutItem, LayoutItemDirection } from './types';
import validateLayout from './validateLayout';
import { canMove, MoveDirection, moveHorizontal, moveVertical } from './movements';
import findItemByKey from './findItemByKey';
describe('movements', () => {
test('should move items horizontally to the right', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
},
{
key: 'col2',
},
{
key: 'col3',
},
],
});
expect(() => moveHorizontal(layout, 'col1', -1)).toThrow();
layout = moveHorizontal(layout, 'col1', 1);
expect(layout.children[0].children[0].key).toBe('col2');
expect(layout.children[0].children[1].key).toBe('col1');
expect(layout.children[1].key).toBe('col3');
layout = moveHorizontal(layout, 'col1', 1);
expect(layout.children[0].key).toBe('col2');
expect(layout.children[1].key).toBe('col1');
expect(layout.children[2].key).toBe('col3');
layout = moveHorizontal(layout, 'col1', 1);
layout = moveHorizontal(layout, 'col1', 1);
expect(() => moveHorizontal(layout, 'col1', 1)).toThrow();
});
test('should move items horizontally to the left', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
direction: LayoutItemDirection.Column,
children: [
{ key: 'item1' },
{ key: 'item2' },
],
},
{
key: 'col2',
},
],
});
layout = moveHorizontal(layout, 'item2', -1);
expect(layout.children[0].key).toBe('item2');
expect(layout.children[1].key).toBe('item1');
expect(layout.children[2].key).toBe('col2');
});
test('should move items vertically', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
},
{
key: 'col2',
direction: LayoutItemDirection.Column,
children: [
{ key: 'row1' },
{ key: 'row2' },
{ key: 'row3' },
],
},
{
key: 'col3',
},
],
});
layout = moveVertical(layout, 'row3', -1);
expect(layout.children[1].children[0].key).toBe('row1');
expect(layout.children[1].children[1].key).toBe('row3');
expect(layout.children[1].children[2].key).toBe('row2');
});
test('should tell if item can be moved', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
resizableRight: true,
direction: LayoutItemDirection.Column,
children: [
{ key: 'row1' },
{ key: 'row2' },
],
},
{
key: 'col2',
},
],
});
expect(canMove(MoveDirection.Up, findItemByKey(layout, 'col1'), findItemByKey(layout, 'root'))).toBe(false);
expect(canMove(MoveDirection.Down, findItemByKey(layout, 'col1'), findItemByKey(layout, 'root'))).toBe(false);
expect(canMove(MoveDirection.Left, findItemByKey(layout, 'col1'), findItemByKey(layout, 'root'))).toBe(false);
expect(canMove(MoveDirection.Right, findItemByKey(layout, 'col1'), findItemByKey(layout, 'root'))).toBe(true);
expect(canMove(MoveDirection.Up, findItemByKey(layout, 'row1'), findItemByKey(layout, 'col1'))).toBe(false);
expect(canMove(MoveDirection.Down, findItemByKey(layout, 'row1'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Left, findItemByKey(layout, 'row1'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Right, findItemByKey(layout, 'row1'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Up, findItemByKey(layout, 'row2'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Down, findItemByKey(layout, 'row2'), findItemByKey(layout, 'col1'))).toBe(false);
expect(canMove(MoveDirection.Left, findItemByKey(layout, 'row2'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Right, findItemByKey(layout, 'row2'), findItemByKey(layout, 'col1'))).toBe(true);
expect(canMove(MoveDirection.Up, findItemByKey(layout, 'col2'), findItemByKey(layout, 'root'))).toBe(false);
expect(canMove(MoveDirection.Down, findItemByKey(layout, 'col2'), findItemByKey(layout, 'root'))).toBe(false);
expect(canMove(MoveDirection.Left, findItemByKey(layout, 'col2'), findItemByKey(layout, 'root'))).toBe(true);
expect(canMove(MoveDirection.Right, findItemByKey(layout, 'col2'), findItemByKey(layout, 'root'))).toBe(false);
});
test('Container with only one child should take the width of its parent', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
},
],
});
layout = moveHorizontal(layout, 'col2', -1);
expect(layout.children[0].children[0].key).toBe('col1');
expect(layout.children[0].children[0].width).toBe(undefined);
});
test('Temp container should take the width of the child it replaces', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 20,
},
{
key: 'col2',
width: 80,
},
{
key: 'col3',
},
],
});
layout = moveHorizontal(layout, 'col2', -1);
expect(layout.children[0].width).toBe(20);
expect(layout.children[0].children[0].width).toBe(undefined);
expect(layout.children[0].children[1].width).toBe(undefined);
});
test('Last child should have flexible width if all siblings have fixed width', () => {
let layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 20,
},
{
key: 'col2',
width: 20,
},
{
key: 'col3',
},
],
});
layout = moveHorizontal(layout, 'col3', -1);
expect(layout.children[0].width).toBe(20);
expect(layout.children[1].width).toBe(undefined);
});
});

View File

@@ -0,0 +1,190 @@
import iterateItems from './iterateItems';
import { LayoutItem, LayoutItemDirection, tempContainerPrefix } from './types';
import produce from 'immer';
import uuid from '@joplin/lib/uuid';
import validateLayout from './validateLayout';
export enum MoveDirection {
Up = 'up',
Down = 'down',
Left = 'left',
Right = 'right',
}
enum MovementDirection {
Horizontal = 1,
Vertical = 2,
}
function array_move(arr: any[], old_index: number, new_index: number) {
arr = arr.slice();
if (new_index >= arr.length) {
let k = new_index - arr.length + 1;
while (k--) {
arr.push(undefined);
}
}
arr.splice(new_index, 0, arr.splice(old_index, 1)[0]);
return arr;
}
function findItemIndex(siblings: LayoutItem[], key: string) {
return siblings.findIndex((value: LayoutItem) => {
return value.key === key;
});
}
function isHorizontalMove(direction: MoveDirection) {
return direction === MoveDirection.Left || direction === MoveDirection.Right;
}
function resetItemSizes(items: LayoutItem[]) {
return items.map((item: LayoutItem) => {
const newItem = { ...item };
delete newItem.width;
delete newItem.height;
return newItem;
});
}
export function canMove(direction: MoveDirection, item: LayoutItem, parent: LayoutItem) {
if (!parent) return false;
if (isHorizontalMove(direction)) {
if (parent.isRoot) {
const idx = direction === MoveDirection.Left ? 0 : parent.children.length - 1;
return parent.children[idx] !== item;
} else if (parent.direction === LayoutItemDirection.Column) {
return true;
}
} else {
if (parent.isRoot) {
return false;
} else if (parent.direction === LayoutItemDirection.Column) {
const idx = direction === MoveDirection.Up ? 0 : parent.children.length - 1;
return parent.children[idx] !== item;
}
}
throw new Error('Unhandled case');
}
// For all movements we make the assumption that there's a root container,
// which is a row of multiple columns. Within each of these columns there
// can be multiple rows (one item per row). Items cannot be more deeply
// nested.
function moveItem(direction: MovementDirection, layout: LayoutItem, key: string, inc: number): LayoutItem {
const itemParents: Record<string, LayoutItem> = {};
const itemIsRoot = (item: LayoutItem) => {
return !itemParents[item.key];
};
const updatedLayout = produce(layout, (draft: any) => {
iterateItems(draft, (itemIndex: number, item: LayoutItem, parent: LayoutItem) => {
itemParents[item.key] = parent;
if (item.key !== key || !parent) return true;
// - "flow" means we are moving an item horizontally within a
// row
// - "contrary" means we are moving an item horizontally within
// a column. Sicen it can't move horizontally, it is moved
// out of its container. And vice-versa for vertical
// movements.
let moveType = null;
if (direction === MovementDirection.Horizontal && parent.direction === LayoutItemDirection.Row) moveType = 'flow';
if (direction === MovementDirection.Horizontal && parent.direction === LayoutItemDirection.Column) moveType = 'contrary';
if (direction === MovementDirection.Vertical && parent.direction === LayoutItemDirection.Column) moveType = 'flow';
if (direction === MovementDirection.Vertical && parent.direction === LayoutItemDirection.Row) moveType = 'contrary';
if (moveType === 'flow') {
const newIndex = itemIndex + inc;
if (newIndex >= parent.children.length || newIndex < 0) throw new Error(`Cannot move item "${key}" from position ${itemIndex} to ${newIndex}`);
// If the item next to it is a container (has children),
// move the item inside the container
if (parent.children[newIndex].children) {
const newParent = parent.children[newIndex];
parent.children.splice(itemIndex, 1);
newParent.children.push(item);
newParent.children = resetItemSizes(newParent.children);
} else {
// If the item is a child of the root container, create
// a new column at `newIndex` and move the item that
// was there, as well as the current item, in this
// container.
if (itemIsRoot(parent)) {
const targetChild = parent.children[newIndex];
// The new container takes the size of the item it
// replaces.
const newSize: any = {};
if (direction === MovementDirection.Horizontal) {
if ('width' in targetChild) newSize.width = targetChild.width;
} else {
if ('height' in targetChild) newSize.height = targetChild.height;
}
const newParent: LayoutItem = {
key: `${tempContainerPrefix}${uuid.createNano()}`,
direction: LayoutItemDirection.Column,
children: [
targetChild,
item,
],
...newSize,
};
parent.children[newIndex] = newParent;
parent.children.splice(itemIndex, 1);
newParent.children = resetItemSizes(newParent.children);
} else {
// Otherwise the default case is simply to move the
// item left/right
parent.children = array_move(parent.children, itemIndex, newIndex);
}
}
} else {
const parentParent = itemParents[parent.key];
const parentIndex = findItemIndex(parentParent.children, parent.key);
parent.children.splice(itemIndex, 1);
let newInc = inc;
if (parent.children.length <= 1) {
parentParent.children[parentIndex] = parent.children[0];
newInc = inc < 0 ? inc + 1 : inc;
}
const newItemIndex = parentIndex + newInc;
parentParent.children.splice(newItemIndex, 0, item);
parentParent.children = resetItemSizes(parentParent.children);
}
return false;
});
});
return validateLayout(updatedLayout);
}
export function moveHorizontal(layout: LayoutItem, key: string, inc: number): LayoutItem {
return moveItem(MovementDirection.Horizontal, layout, key, inc);
}
export function moveVertical(layout: LayoutItem, key: string, inc: number): LayoutItem {
return moveItem(MovementDirection.Vertical, layout, key, inc);
}
export function move(layout: LayoutItem, key: string, direction: MoveDirection): LayoutItem {
if (direction === MoveDirection.Up) return moveVertical(layout, key, -1);
if (direction === MoveDirection.Down) return moveVertical(layout, key, +1);
if (direction === MoveDirection.Left) return moveHorizontal(layout, key, -1);
if (direction === MoveDirection.Right) return moveHorizontal(layout, key, +1);
throw new Error('Unreachable');
}

View File

@@ -0,0 +1,77 @@
import { loadLayout, saveLayout } from './persist';
import { LayoutItem, LayoutItemDirection } from './types';
import validateLayout from './validateLayout';
describe('persist', () => {
test('should save layout and filter out non-user properties', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 100,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
direction: LayoutItemDirection.Column,
children: [
{ key: 'item1', height: 20 },
{ key: 'item2' },
],
},
{
key: 'col3',
},
],
});
const toSave = saveLayout(layout);
expect(toSave.key).toBe('root');
expect(toSave.width).toBeUndefined();
expect(toSave.height).toBeUndefined();
expect(toSave.direction).toBeUndefined();
expect(toSave.children.length).toBe(3);
expect(toSave.children[1].key).toBe('col2');
expect(toSave.children[1].direction).toBeUndefined();
});
test('should load a layout', () => {
const layout: any = {
key: 'root',
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
children: [
{ key: 'item1', height: 20 },
{ key: 'item2' },
],
},
{
key: 'col3',
},
],
};
const loaded = loadLayout(layout, null, { width: 100, height: 200 });
expect(loaded.key).toBe('root');
expect(loaded.width).toBe(100);
expect(loaded.height).toBe(200);
expect(loaded.direction).toBe(LayoutItemDirection.Row);
expect(loaded.children.length).toBe(3);
expect(loaded.children[1].key).toBe('col2');
expect(loaded.children[1].direction).toBe(LayoutItemDirection.Column);
});
});

View File

@@ -0,0 +1,41 @@
import { LayoutItem, Size } from './types';
import produce from 'immer';
import iterateItems from './iterateItems';
import validateLayout from './validateLayout';
export function saveLayout(layout: LayoutItem): any {
const propertyWhiteList = [
'visible',
'width',
'height',
'children',
'key',
'context',
];
return produce(layout, (draft: any) => {
delete draft.width;
delete draft.height;
iterateItems(draft, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => {
for (const k of Object.keys(item)) {
if (!propertyWhiteList.includes(k)) delete (item as any)[k];
}
return true;
});
});
}
export function loadLayout(layout: any, defaultLayout: LayoutItem, rootSize: Size): LayoutItem {
let output: LayoutItem = null;
if (layout) {
output = { ...layout };
} else {
output = { ...defaultLayout };
}
output.width = rootSize.width;
output.height = rootSize.height;
return validateLayout(output);
}

View File

@@ -0,0 +1,18 @@
import produce from 'immer';
import iterateItems from './iterateItems';
import { LayoutItem } from './types';
import validateLayout from './validateLayout';
export default function(layout: LayoutItem, itemKey: string): LayoutItem {
const output = produce(layout, (layoutDraft: LayoutItem) => {
iterateItems(layoutDraft, (itemIndex: number, item: LayoutItem, parent: LayoutItem) => {
if (item.key === itemKey) {
parent.children.splice(itemIndex, 1);
return false;
}
return true;
});
});
return output !== layout ? validateLayout(output) : layout;
}

View File

@@ -0,0 +1,23 @@
import produce from 'immer';
import { LayoutItem } from './types';
import validateLayout from './validateLayout';
export default function setLayoutItemProps(layout: LayoutItem, key: string, props: any) {
return validateLayout(produce(layout, (draftState: LayoutItem) => {
function recurseFind(item: LayoutItem) {
if (item.key === key) {
for (const n in props) {
(item as any)[n] = props[n];
}
} else {
if (item.children) {
for (const child of item.children) {
recurseFind(child);
}
}
}
}
recurseFind(draftState);
}));
}

View File

@@ -0,0 +1,38 @@
import { ThemeAppearance } from '@joplin/lib/themes/type';
import styled from 'styled-components';
export const StyledWrapperRoot = styled.div`
position: relative;
display: flex;
width: ${props => props.size.width}px;
height: ${props => props.size.height}px;
`;
export const StyledMoveOverlay = styled.div`
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
background-color: ${props => props.theme.appearance === ThemeAppearance.Light ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.5)'};
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
`;
export const MoveModeRootWrapper = styled.div`
position:relative;
display: flex;
align-items: center;
justify-content: center;
`;
export const MoveModeRootMessage = styled.div`
position:absolute;
bottom: 10px;
z-index:200;
background-color: ${props => props.theme.backgroundColor};
padding: 10px;
border-radius: 5;
`;

View File

@@ -0,0 +1,26 @@
export enum LayoutItemDirection {
Row = 'row',
Column = 'column',
}
export interface Size {
width: number;
height: number;
}
export interface LayoutItem {
key: string;
isRoot?: boolean;
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
children?: LayoutItem[];
direction?: LayoutItemDirection;
resizableRight?: boolean;
resizableBottom?: boolean;
visible?: boolean;
context?: any;
}
export const tempContainerPrefix = 'tempContainer-';

View File

@@ -0,0 +1,89 @@
import useLayoutItemSizes, { itemSize } from './useLayoutItemSizes';
import { LayoutItem, LayoutItemDirection } from './types';
import { renderHook } from '@testing-library/react-hooks';
import validateLayout from './validateLayout';
describe('useLayoutItemSizes', () => {
test('should validate the layout', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{ key: 'col1' },
{ key: 'col2' },
],
});
expect(layout.isRoot).toBe(true);
});
test('should give item sizes', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
width: 50,
},
{
key: 'col2',
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes.root).toEqual({ width: 200, height: 100 });
expect(sizes.col1).toEqual({ width: 50, height: 100 });
expect(sizes.col2).toEqual({ width: 150, height: 100 });
});
test('should leave room for the resizer controls', () => {
const layout: LayoutItem = validateLayout({
key: 'root',
width: 200,
height: 100,
direction: LayoutItemDirection.Row,
children: [
{
key: 'col1',
resizableRight: true,
direction: LayoutItemDirection.Column,
children: [
{ key: 'row1', resizableBottom: true },
{ key: 'row2' },
],
},
{
key: 'col2',
},
],
});
const { result } = renderHook(() => useLayoutItemSizes(layout));
const sizes = result.current;
expect(sizes).toEqual({
root: { width: 200, height: 100 },
col1: { width: 100, height: 100 },
col2: { width: 100, height: 100 },
row1: { width: 100, height: 50 },
row2: { width: 100, height: 50 },
});
expect(itemSize(layout.children[0], layout, sizes, true)).toEqual({ width: 100, height: 100 });
const parent = layout.children[0];
expect(itemSize(parent.children[0], parent, sizes, false)).toEqual({ width: 95, height: 45 });
expect(itemSize(parent.children[1], parent, sizes, false)).toEqual({ width: 95, height: 50 });
});
});

View File

@@ -0,0 +1,95 @@
import { useMemo } from 'react';
import { LayoutItem, Size } from './types';
const dragBarThickness = 5;
export interface LayoutItemSizes {
[key: string]: Size;
}
// Container always take the full space while the items within it need to
// accomodate for the resize handle.
export function itemSize(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, isContainer: boolean): Size {
const parentResizableRight = !!parent && parent.resizableRight;
const parentResizableBottom = !!parent && parent.resizableBottom;
const rightGap = !isContainer && (item.resizableRight || parentResizableRight) ? dragBarThickness : 0;
const bottomGap = !isContainer && (item.resizableBottom || parentResizableBottom) ? dragBarThickness : 0;
return {
width: ('width' in item ? item.width : sizes[item.key].width) - rightGap,
height: ('height' in item ? item.height : sizes[item.key].height) - bottomGap,
};
}
// This calculate the size of each item within the layout. However
// the final size, as rendered by the component is determined by
// `itemSize()`, as it takes into account the resizer handle
function calculateChildrenSizes(item: LayoutItem, parent: LayoutItem | null, sizes: LayoutItemSizes, makeAllVisible: boolean): LayoutItemSizes {
if (!item.children) return sizes;
const parentSize = itemSize(item, parent, sizes, true);
const remainingSize: Size = {
width: parentSize.width,
height: parentSize.height,
};
const noWidthChildren: any[] = [];
const noHeightChildren: any[] = [];
for (const child of item.children) {
let w = 'width' in child ? child.width : null;
let h = 'height' in child ? child.height : null;
if (!makeAllVisible && child.visible === false) {
w = 0;
h = 0;
}
sizes[child.key] = { width: w, height: h };
if (w !== null) remainingSize.width -= w;
if (h !== null) remainingSize.height -= h;
if (w === null) noWidthChildren.push({ item: child, parent: item });
if (h === null) noHeightChildren.push({ item: child, parent: item });
}
if (noWidthChildren.length) {
const w = item.direction === 'row' ? Math.floor(remainingSize.width / noWidthChildren.length) : parentSize.width;
for (const child of noWidthChildren) {
const finalWidth = w;
sizes[child.item.key].width = finalWidth;
}
}
if (noHeightChildren.length) {
const h = item.direction === 'column' ? Math.floor(remainingSize.height / noHeightChildren.length) : parentSize.height;
for (const child of noHeightChildren) {
const finalHeight = h;
sizes[child.item.key].height = finalHeight;
}
}
for (const child of item.children) {
const childrenSizes = calculateChildrenSizes(child, parent, sizes, makeAllVisible);
sizes = { ...sizes, ...childrenSizes };
}
return sizes;
}
export default function useLayoutItemSizes(layout: LayoutItem, makeAllVisible: boolean = false) {
return useMemo(() => {
let sizes: LayoutItemSizes = {};
if (!('width' in layout) || !('height' in layout)) throw new Error('width and height are required on layout root');
sizes[layout.key] = {
width: layout.width,
height: layout.height,
};
sizes = calculateChildrenSizes(layout, null, sizes, makeAllVisible);
return sizes;
}, [layout, makeAllVisible]);
}

View File

@@ -0,0 +1,103 @@
import produce from 'immer';
import iterateItems from './iterateItems';
import { LayoutItem, LayoutItemDirection } from './types';
function updateItemSize(itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) {
if (!parent) return;
// If a container has only one child, this child should not
// have a width and height, and simply fill up the container
if (parent.children.length === 1) {
delete itemDraft.width;
delete itemDraft.height;
}
// If all children of a container have a fixed width, the
// latest child should have a flexible width (i.e. no "width"
// property), so that it fills up the remaining space
if (itemIndex === parent.children.length - 1) {
let allChildrenAreSized = true;
for (const child of parent.children) {
if (parent.direction === LayoutItemDirection.Row) {
if (!child.width) {
allChildrenAreSized = false;
break;
}
} else {
if (!child.height) {
allChildrenAreSized = false;
break;
}
}
}
if (allChildrenAreSized) {
if (parent.direction === LayoutItemDirection.Row) {
delete itemDraft.width;
} else {
delete itemDraft.height;
}
}
}
}
// All items should be resizable, except for the root and the latest child
// of a container.
function updateResizeRules(itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) {
if (!parent) return;
const isLastChild = itemIndex === parent.children.length - 1;
itemDraft.resizableRight = parent.direction === LayoutItemDirection.Row && !isLastChild;
itemDraft.resizableBottom = parent.direction === LayoutItemDirection.Column && !isLastChild;
}
// Container direction should alternate between row (for the root) and
// columns, then rows again.
function updateDirection(_itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) {
if (!parent) {
itemDraft.direction = LayoutItemDirection.Row;
} else {
itemDraft.direction = parent.direction === LayoutItemDirection.Row ? LayoutItemDirection.Column : LayoutItemDirection.Row;
}
}
function itemShouldBeVisible(item: LayoutItem): boolean {
if (!item.children) return item.visible !== false;
let oneIsVisible = false;
for (const child of item.children) {
if (itemShouldBeVisible(child)) {
oneIsVisible = true;
break;
}
}
return oneIsVisible;
}
// If all children of a container are hidden, the container should be
// hidden too. A container visiblity cannot be changed by the user.
function updateContainerVisibility(_itemIndex: number, itemDraft: LayoutItem, _parent: LayoutItem) {
if (itemDraft.children) {
itemDraft.visible = itemShouldBeVisible(itemDraft);
} else {
itemDraft.visible = itemDraft.visible !== false;
}
}
export default function validateLayout(layout: LayoutItem): LayoutItem {
if (!layout) throw new Error('Layout is null');
if (!layout.children || !layout.children.length) throw new Error('Root does not have children');
return produce(layout, (draft: LayoutItem) => {
draft.isRoot = true;
iterateItems(draft, (itemIndex: number, itemDraft: LayoutItem, parent: LayoutItem) => {
updateItemSize(itemIndex, itemDraft, parent);
updateResizeRules(itemIndex, itemDraft, parent);
updateDirection(itemIndex, itemDraft, parent);
updateContainerVisibility(itemIndex, itemDraft, parent);
return true;
});
});
}

View File

@@ -6,7 +6,7 @@ import OneDriveLoginScreen from './OneDriveLoginScreen';
import DropboxLoginScreen from './DropboxLoginScreen';
import ErrorBoundary from './ErrorBoundary';
import { themeStyle } from '@joplin/lib/theme';
import { Size } from './ResizableLayout/ResizableLayout';
import { Size } from './ResizableLayout/utils/types';
import MenuBar from './MenuBar';
import { _ } from '@joplin/lib/locale';
const React = require('react');
@@ -67,20 +67,10 @@ async function initialize() {
type: 'NOTE_VISIBLE_PANES_SET',
panes: Setting.value('noteVisiblePanes'),
});
store.dispatch({
type: 'SIDEBAR_VISIBILITY_SET',
visibility: Setting.value('sidebarVisibility'),
});
store.dispatch({
type: 'NOTELIST_VISIBILITY_SET',
visibility: Setting.value('noteListVisibility'),
});
}
class RootComponent extends React.Component<Props, any> {
async componentDidMount() {
public async componentDidMount() {
if (this.props.appState == 'starting') {
this.props.dispatch({
type: 'APP_STATE_SET',
@@ -98,7 +88,7 @@ class RootComponent extends React.Component<Props, any> {
await WelcomeUtils.install(this.props.dispatch);
}
render() {
public render() {
const navigatorStyle = {
width: this.props.size.width / this.props.zoomFactor,
height: this.props.size.height / this.props.zoomFactor,

View File

@@ -685,8 +685,6 @@ const mapStateToProps = (state: any) => {
collapsedFolderIds: state.collapsedFolderIds,
decryptionWorker: state.decryptionWorker,
resourceFetcher: state.resourceFetcher,
sidebarVisibility: state.sidebarVisibility,
noteListVisibility: state.noteListVisibility,
};
};

View File

@@ -1,5 +1,7 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import layoutItemProp from '../../ResizableLayout/utils/layoutItemProp';
import { AppState } from '../../../app';
export const declaration: CommandDeclaration = {
name: 'focusElementSideBar',
@@ -9,8 +11,8 @@ export const declaration: CommandDeclaration = {
export const runtime = (comp: any): CommandRuntime => {
return {
execute: async (context: any) => {
const sideBarVisible = !!context.state.sidebarVisibility;
execute: async (context: CommandContext) => {
const sideBarVisible = layoutItemProp((context.state as AppState).mainLayout, 'sideBar', 'visible');
if (sideBarVisible) {
const item = comp.selectedItem();

View File

@@ -30,6 +30,7 @@ export default function() {
'textPaste',
'textSelectAll',
'toggleExternalEditing',
'toggleLayoutMoveMode',
'toggleNoteList',
'toggleSideBar',
'toggleVisiblePanes',