You've already forked joplin
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:
@@ -18,6 +18,9 @@ import SpellCheckerService from '@joplin/lib/services/spellChecker/SpellCheckerS
|
||||
import SpellCheckerServiceDriverNative from './services/spellChecker/SpellCheckerServiceDriverNative';
|
||||
import bridge from './services/bridge';
|
||||
import menuCommandNames from './gui/menuCommandNames';
|
||||
import { LayoutItem } from './gui/ResizableLayout/utils/types';
|
||||
import stateToWhenClauseContext from './services/commands/stateToWhenClauseContext';
|
||||
import ResourceService from '@joplin/lib/services/ResourceService';
|
||||
|
||||
const { FoldersScreenUtils } = require('@joplin/lib/folders-screen-utils.js');
|
||||
const MasterKey = require('@joplin/lib/models/MasterKey');
|
||||
@@ -27,7 +30,6 @@ const Tag = require('@joplin/lib/models/Tag.js');
|
||||
const { reg } = require('@joplin/lib/registry.js');
|
||||
const packageInfo = require('./packageInfo.js');
|
||||
const DecryptionWorker = require('@joplin/lib/services/DecryptionWorker');
|
||||
const ResourceService = require('@joplin/lib/services/ResourceService').default;
|
||||
const ClipperServer = require('@joplin/lib/ClipperServer');
|
||||
const ExternalEditWatcher = require('@joplin/lib/services/ExternalEditWatcher');
|
||||
const { webFrame } = require('electron');
|
||||
@@ -67,6 +69,7 @@ const commands = [
|
||||
require('./gui/MainScreen/commands/openNote'),
|
||||
require('./gui/MainScreen/commands/openFolder'),
|
||||
require('./gui/MainScreen/commands/openTag'),
|
||||
require('./gui/MainScreen/commands/toggleLayoutMoveMode'),
|
||||
require('./gui/NoteEditor/commands/focusElementNoteBody'),
|
||||
require('./gui/NoteEditor/commands/focusElementNoteTitle'),
|
||||
require('./gui/NoteEditor/commands/showLocalSearch'),
|
||||
@@ -105,17 +108,17 @@ export interface AppState extends State {
|
||||
route: AppStateRoute;
|
||||
navHistory: any[];
|
||||
noteVisiblePanes: string[];
|
||||
sidebarVisibility: boolean;
|
||||
noteListVisibility: boolean;
|
||||
windowContentSize: any;
|
||||
watchedNoteFiles: string[];
|
||||
lastEditorScrollPercents: any;
|
||||
devToolsVisible: boolean;
|
||||
visibleDialogs: any; // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
|
||||
focusedField: string;
|
||||
layoutMoveMode: boolean;
|
||||
|
||||
// Extra reducer keys go here
|
||||
watchedResources: any;
|
||||
mainLayout: LayoutItem;
|
||||
}
|
||||
|
||||
const appDefaultState: AppState = {
|
||||
@@ -127,14 +130,14 @@ const appDefaultState: AppState = {
|
||||
},
|
||||
navHistory: [],
|
||||
noteVisiblePanes: ['editor', 'viewer'],
|
||||
sidebarVisibility: true,
|
||||
noteListVisibility: true,
|
||||
windowContentSize: bridge().windowContentSize(),
|
||||
watchedNoteFiles: [],
|
||||
lastEditorScrollPercents: {},
|
||||
devToolsVisible: false,
|
||||
visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
|
||||
focusedField: null,
|
||||
layoutMoveMode: false,
|
||||
mainLayout: null,
|
||||
...resourceEditWatcherDefaultState,
|
||||
};
|
||||
|
||||
@@ -234,25 +237,12 @@ class Application extends BaseApplication {
|
||||
newState.noteVisiblePanes = action.panes;
|
||||
break;
|
||||
|
||||
case 'SIDEBAR_VISIBILITY_TOGGLE':
|
||||
case 'MAIN_LAYOUT_SET':
|
||||
|
||||
newState = Object.assign({}, state);
|
||||
newState.sidebarVisibility = !state.sidebarVisibility;
|
||||
break;
|
||||
|
||||
case 'SIDEBAR_VISIBILITY_SET':
|
||||
newState = Object.assign({}, state);
|
||||
newState.sidebarVisibility = action.visibility;
|
||||
break;
|
||||
|
||||
case 'NOTELIST_VISIBILITY_TOGGLE':
|
||||
newState = Object.assign({}, state);
|
||||
newState.noteListVisibility = !state.noteListVisibility;
|
||||
break;
|
||||
|
||||
case 'NOTELIST_VISIBILITY_SET':
|
||||
newState = Object.assign({}, state);
|
||||
newState.noteListVisibility = action.visibility;
|
||||
newState = {
|
||||
...state,
|
||||
mainLayout: action.value,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'NOTE_FILE_WATCHER_ADD':
|
||||
@@ -333,6 +323,14 @@ class Application extends BaseApplication {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'LAYOUT_MOVE_MODE_SET':
|
||||
|
||||
newState = {
|
||||
...state,
|
||||
layoutMoveMode: action.value,
|
||||
};
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
@@ -384,14 +382,6 @@ class Application extends BaseApplication {
|
||||
Setting.setValue('noteVisiblePanes', newState.noteVisiblePanes);
|
||||
}
|
||||
|
||||
if (['SIDEBAR_VISIBILITY_TOGGLE', 'SIDEBAR_VISIBILITY_SET'].indexOf(action.type) >= 0) {
|
||||
Setting.setValue('sidebarVisibility', newState.sidebarVisibility);
|
||||
}
|
||||
|
||||
if (['NOTELIST_VISIBILITY_TOGGLE', 'NOTELIST_VISIBILITY_SET'].indexOf(action.type) >= 0) {
|
||||
Setting.setValue('noteListVisibility', newState.noteListVisibility);
|
||||
}
|
||||
|
||||
if (['NOTE_DEVTOOLS_TOGGLE', 'NOTE_DEVTOOLS_SET'].indexOf(action.type) >= 0) {
|
||||
this.toggleDevTools(newState.devToolsVisible);
|
||||
}
|
||||
@@ -497,6 +487,37 @@ class Application extends BaseApplication {
|
||||
return cssString;
|
||||
}
|
||||
|
||||
private async initPluginService() {
|
||||
const pluginLogger = new Logger();
|
||||
pluginLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-plugins.txt` });
|
||||
pluginLogger.addTarget(TargetType.Console, { prefix: 'Plugin Service:' });
|
||||
pluginLogger.setLevel(Setting.value('env') == 'dev' ? Logger.LEVEL_DEBUG : Logger.LEVEL_INFO);
|
||||
|
||||
const pluginRunner = new PluginRunner();
|
||||
PluginService.instance().setLogger(pluginLogger);
|
||||
PluginService.instance().initialize(PlatformImplementation.instance(), pluginRunner, this.store());
|
||||
|
||||
try {
|
||||
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) await PluginService.instance().loadAndRunPlugins(Setting.value('pluginDir'));
|
||||
} catch (error) {
|
||||
this.logger().error(`There was an error loading plugins from ${Setting.value('pluginDir')}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Setting.value('plugins.devPluginPaths')) {
|
||||
const paths = Setting.value('plugins.devPluginPaths').split(',').map((p: string) => p.trim());
|
||||
await PluginService.instance().loadAndRunPlugins(paths);
|
||||
}
|
||||
|
||||
// Also load dev plugins that have passed via command line arguments
|
||||
if (Setting.value('startupDevPlugins')) {
|
||||
await PluginService.instance().loadAndRunPlugins(Setting.value('startupDevPlugins'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error(`There was an error loading plugins from ${Setting.value('plugins.devPluginPaths')}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async start(argv: string[]): Promise<any> {
|
||||
const electronIsDev = require('electron-is-dev');
|
||||
|
||||
@@ -539,7 +560,7 @@ class Application extends BaseApplication {
|
||||
|
||||
this.initRedux();
|
||||
|
||||
CommandService.instance().initialize(this.store(), Setting.value('env') == 'dev');
|
||||
CommandService.instance().initialize(this.store(), Setting.value('env') == 'dev', stateToWhenClauseContext);
|
||||
|
||||
for (const command of commands) {
|
||||
CommandService.instance().registerDeclaration(command.declaration);
|
||||
@@ -688,34 +709,7 @@ class Application extends BaseApplication {
|
||||
|
||||
bridge().addEventListener('nativeThemeUpdated', this.bridge_nativeThemeUpdated);
|
||||
|
||||
const pluginLogger = new Logger();
|
||||
pluginLogger.addTarget(TargetType.File, { path: `${Setting.value('profileDir')}/log-plugins.txt` });
|
||||
pluginLogger.addTarget(TargetType.Console, { prefix: 'Plugin Service:' });
|
||||
pluginLogger.setLevel(Setting.value('env') == 'dev' ? Logger.LEVEL_DEBUG : Logger.LEVEL_INFO);
|
||||
|
||||
const pluginRunner = new PluginRunner();
|
||||
PluginService.instance().setLogger(pluginLogger);
|
||||
PluginService.instance().initialize(PlatformImplementation.instance(), pluginRunner, this.store());
|
||||
|
||||
try {
|
||||
if (await shim.fsDriver().exists(Setting.value('pluginDir'))) await PluginService.instance().loadAndRunPlugins(Setting.value('pluginDir'));
|
||||
} catch (error) {
|
||||
this.logger().error(`There was an error loading plugins from ${Setting.value('pluginDir')}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (Setting.value('plugins.devPluginPaths')) {
|
||||
const paths = Setting.value('plugins.devPluginPaths').split(',').map((p: string) => p.trim());
|
||||
await PluginService.instance().loadAndRunPlugins(paths);
|
||||
}
|
||||
|
||||
// Also load dev plugins that have passed via command line arguments
|
||||
if (Setting.value('startupDevPlugins')) {
|
||||
await PluginService.instance().loadAndRunPlugins(Setting.value('startupDevPlugins'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger().error(`There was an error loading plugins from ${Setting.value('plugins.devPluginPaths')}:`, error);
|
||||
}
|
||||
await this.initPluginService();
|
||||
|
||||
this.setupContextMenu();
|
||||
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx
Normal file
39
packages/app-desktop/gui/NoteListWrapper/NoteListWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
80
packages/app-desktop/gui/ResizableLayout/MoveButtons.tsx
Normal file
80
packages/app-desktop/gui/ResizableLayout/MoveButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tempContainerPrefix } from './types';
|
||||
|
||||
export default function(itemKey: string): boolean {
|
||||
return itemKey.indexOf(tempContainerPrefix) === 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
228
packages/app-desktop/gui/ResizableLayout/utils/movements.test.ts
Normal file
228
packages/app-desktop/gui/ResizableLayout/utils/movements.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
190
packages/app-desktop/gui/ResizableLayout/utils/movements.ts
Normal file
190
packages/app-desktop/gui/ResizableLayout/utils/movements.ts
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
41
packages/app-desktop/gui/ResizableLayout/utils/persist.ts
Normal file
41
packages/app-desktop/gui/ResizableLayout/utils/persist.ts
Normal 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);
|
||||
}
|
||||
18
packages/app-desktop/gui/ResizableLayout/utils/removeItem.ts
Normal file
18
packages/app-desktop/gui/ResizableLayout/utils/removeItem.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}));
|
||||
}
|
||||
38
packages/app-desktop/gui/ResizableLayout/utils/style.ts
Normal file
38
packages/app-desktop/gui/ResizableLayout/utils/style.ts
Normal 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;
|
||||
`;
|
||||
26
packages/app-desktop/gui/ResizableLayout/utils/types.ts
Normal file
26
packages/app-desktop/gui/ResizableLayout/utils/types.ts
Normal 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-';
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
103
packages/app-desktop/gui/ResizableLayout/utils/validateLayout.ts
Normal file
103
packages/app-desktop/gui/ResizableLayout/utils/validateLayout.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -685,8 +685,6 @@ const mapStateToProps = (state: any) => {
|
||||
collapsedFolderIds: state.collapsedFolderIds,
|
||||
decryptionWorker: state.decryptionWorker,
|
||||
resourceFetcher: state.resourceFetcher,
|
||||
sidebarVisibility: state.sidebarVisibility,
|
||||
noteListVisibility: state.noteListVisibility,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function() {
|
||||
'textPaste',
|
||||
'textSelectAll',
|
||||
'toggleExternalEditing',
|
||||
'toggleLayoutMoveMode',
|
||||
'toggleNoteList',
|
||||
'toggleSideBar',
|
||||
'toggleVisiblePanes',
|
||||
|
||||
191
packages/app-desktop/jest.config.js
Normal file
191
packages/app-desktop/jest.config.js
Normal file
@@ -0,0 +1,191 @@
|
||||
// For a detailed explanation regarding each configuration property, visit:
|
||||
// https://jestjs.io/docs/en/configuration.html
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "/tmp/jest_rs",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
// collectCoverageFrom: undefined,
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
// coverageDirectory: undefined,
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: 'v8',
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
// globals: {},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
// moduleNameMapper: {},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
// preset: undefined,
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
// resolver: undefined,
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
// roots: [
|
||||
// "<rootDir>"
|
||||
// ],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: 'node',
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
'**/*.test.js',
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "/node_modules/"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
// transform: undefined,
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "/node_modules/",
|
||||
// "\\.pnp\\.[^\\/]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
};
|
||||
4539
packages/app-desktop/package-lock.json
generated
4539
packages/app-desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
||||
"postinstall": "npm run build && gulp electronRebuild",
|
||||
"tsc": "node node_modules/typescript/bin/tsc --project tsconfig.json",
|
||||
"watch": "node node_modules/typescript/bin/tsc --watch --project tsconfig.json",
|
||||
"start": "gulp build && electron . --env dev --log-level debug --no-welcome --open-dev-tools"
|
||||
"start": "gulp build && electron . --env dev --log-level debug --no-welcome --open-dev-tools",
|
||||
"test": "jest"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -86,11 +87,13 @@
|
||||
},
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "^1.0.9",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@types/jasmine": "^3.5.11",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/react": "16.9.55",
|
||||
"@types/react-redux": "^7.1.9",
|
||||
"@joplin/tools": "^1.0.9",
|
||||
"ajv": "^6.5.0",
|
||||
"app-builder-bin": "^1.9.11",
|
||||
"babel-cli": "^6.26.0",
|
||||
@@ -100,7 +103,9 @@
|
||||
"electron-rebuild": "^1.10.1",
|
||||
"glob": "^7.1.6",
|
||||
"gulp": "^4.0.2",
|
||||
"jest": "^26.6.3",
|
||||
"js-sha512": "^0.8.0",
|
||||
"react-test-renderer": "^16.14.0",
|
||||
"typescript": "^4.0.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -112,6 +117,7 @@
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joplin/lib": "^1.0.9",
|
||||
"@joplin/renderer": "^1.0.17",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
"color": "^3.1.2",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// This extends the generic stateToWhenClauseContext (potentially shared by
|
||||
// all apps) with additional properties specific to the desktop app. So in
|
||||
// general, any desktop component should import this file, and not the lib
|
||||
// one.
|
||||
|
||||
import { AppState } from '../../app';
|
||||
import libStateToWhenClauseContext from '@joplin/lib/services/commands/stateToWhenClauseContext';
|
||||
import layoutItemProp from '../../gui/ResizableLayout/utils/layoutItemProp';
|
||||
|
||||
export default function stateToWhenClauseContext(state: AppState) {
|
||||
return {
|
||||
...libStateToWhenClauseContext(state),
|
||||
|
||||
// UI elements
|
||||
markdownEditorVisible: !!state.settings['editor.codeView'],
|
||||
richTextEditorVisible: !state.settings['editor.codeView'],
|
||||
markdownEditorPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('editor'),
|
||||
markdownViewerPaneVisible: state.settings['editor.codeView'] && state.noteVisiblePanes.includes('viewer'),
|
||||
modalDialogVisible: !!Object.keys(state.visibleDialogs).length,
|
||||
sideBarVisible: !!state.mainLayout && layoutItemProp(state.mainLayout, 'sideBar', 'visible'),
|
||||
noteListHasNotes: !!state.notes.length,
|
||||
};
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
# This is a convenient way to build and test a plugin demo.
|
||||
# It could be used to develop plugins too.
|
||||
|
||||
PLUGIN_PATH=/home/laurent/source/joplin/packages/app-cli/tests/support/plugins/content_script
|
||||
PLUGIN_PATH=/home/laurent/source/joplin/packages/app-cli/tests/support/plugins/register_command
|
||||
npm i --prefix="$PLUGIN_PATH" && npm start -- --dev-plugins "$PLUGIN_PATH"
|
||||
Reference in New Issue
Block a user