mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-07-28 04:08:05 +00:00
app: Improve split view behavior.
**Move activity buttons to main toolbar** This was on a vscode-style sidebar because we originally thought we would add more. It turns out we will not. Having explorer/settings with the other buttons simplifies the UI. This also makes the buttons not jump around when you open/close the explorer. Also improves on small screens, where the explorer would obscure all buttons. Also lets us remove the blueprint hacks we had to make this work. **Add toggles for sideviews.** This lets us fix several UI/UX problems. It used to be easy to lose the terminal or docs forever. Now you can toggle them back on with a button and conveniently close them rather than dragging them down. **Improve splitters** Switch to react-resizable-panels which lets us control the split as needed. Also when dragging to near closed, it will snap to properly closed. Toggling on then restores normal size. Otherwise it preserves the split value. The collapsed state of this library is made for hiding the docs without destroying them, so we don't need some of the hacks we had to preserve the terminal and docs state.
This commit is contained in:
+1
-2
@@ -31,7 +31,6 @@
|
|||||||
"@types/node": "^18.19.14",
|
"@types/node": "^18.19.14",
|
||||||
"@types/react": "^18.2.47",
|
"@types/react": "^18.2.47",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-splitter-layout": "^3.0.5",
|
|
||||||
"@types/react-transition-group": "^4.4.10",
|
"@types/react-transition-group": "^4.4.10",
|
||||||
"@types/redux-logger": "^3.0.12",
|
"@types/redux-logger": "^3.0.12",
|
||||||
"@types/semver": "^7.5.6",
|
"@types/semver": "^7.5.6",
|
||||||
@@ -99,7 +98,7 @@
|
|||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
"react-splitter-layout": "^4.0.0",
|
"react-resizable-panels": "^4.8.0",
|
||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
"redux-logger": "^3.0.6",
|
"redux-logger": "^3.0.6",
|
||||||
"redux-saga": "^1.3.0",
|
"redux-saga": "^1.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||||
|
|
||||||
import { act, cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { testRender } from '../../test';
|
import { testRender } from '../../test';
|
||||||
import Activities from './Activities';
|
import Activities from './Activities';
|
||||||
@@ -15,66 +15,37 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Activities', () => {
|
describe('Activities', () => {
|
||||||
it('should select explorer by default', () => {
|
it('should render explorer by default', () => {
|
||||||
const [, activities] = testRender(<Activities />);
|
const [, activities] = testRender(<Activities />);
|
||||||
|
|
||||||
const tab = activities.getByRole('tab', { name: 'File Explorer' });
|
expect(
|
||||||
|
activities.container.querySelector('.pb-activities-tabview'),
|
||||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use localStorage for default value', () => {
|
it('should render settings when Settings activity is selected', () => {
|
||||||
localStorage.setItem(
|
sessionStorage.setItem(
|
||||||
'activities.selectedActivity',
|
'activities.selectedActivity',
|
||||||
JSON.stringify(Activity.Settings),
|
JSON.stringify(Activity.Settings),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [, activities] = testRender(<Activities />);
|
const [, activities] = testRender(<Activities />);
|
||||||
|
|
||||||
const tab = activities.getByRole('tab', { name: 'Settings & Help' });
|
expect(
|
||||||
|
activities.container.querySelector('.pb-activities-tabview'),
|
||||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select none when clicking already selected tab', async () => {
|
it('should render nothing when no activity is selected', () => {
|
||||||
const [user, activities] = testRender(<Activities />);
|
sessionStorage.setItem(
|
||||||
|
'activities.selectedActivity',
|
||||||
|
JSON.stringify(Activity.None),
|
||||||
|
);
|
||||||
|
|
||||||
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
|
const [, activities] = testRender(<Activities />);
|
||||||
|
|
||||||
for (const tab of activities.getAllByRole('tab')) {
|
expect(
|
||||||
expect(tab).toHaveAttribute(
|
activities.container.querySelector('.pb-activities-tabview'),
|
||||||
'aria-selected',
|
).not.toBeInTheDocument();
|
||||||
tab === explorerTab ? 'true' : 'false',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await act(() => user.click(explorerTab));
|
|
||||||
|
|
||||||
for (const tab of activities.getAllByRole('tab')) {
|
|
||||||
expect(tab).toHaveAttribute('aria-selected', 'false');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should select new tab when clicking not already selected tab', async () => {
|
|
||||||
const [user, activities] = testRender(<Activities />);
|
|
||||||
|
|
||||||
const explorerTab = activities.getByRole('tab', { name: 'File Explorer' });
|
|
||||||
const settingsTab = activities.getByRole('tab', { name: 'Settings & Help' });
|
|
||||||
|
|
||||||
for (const tab of activities.getAllByRole('tab')) {
|
|
||||||
expect(tab).toHaveAttribute(
|
|
||||||
'aria-selected',
|
|
||||||
tab === explorerTab ? 'true' : 'false',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await act(() => user.click(settingsTab));
|
|
||||||
|
|
||||||
for (const tab of activities.getAllByRole('tab')) {
|
|
||||||
expect(tab).toHaveAttribute(
|
|
||||||
'aria-selected',
|
|
||||||
tab === settingsTab ? 'true' : 'false',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-128
@@ -2,143 +2,33 @@
|
|||||||
// Copyright (c) 2022-2023 The Pybricks Authors
|
// Copyright (c) 2022-2023 The Pybricks Authors
|
||||||
|
|
||||||
import './activities.scss';
|
import './activities.scss';
|
||||||
import { Icon, Tab, Tabs } from '@blueprintjs/core';
|
import React from 'react';
|
||||||
import { Cog, Document } from '@blueprintjs/icons';
|
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import Explorer from '../explorer/Explorer';
|
import Explorer from '../explorer/Explorer';
|
||||||
import Settings from '../settings/Settings';
|
import Settings from '../settings/Settings';
|
||||||
import { Activity, useActivitiesSelectedActivity } from './hooks';
|
import { Activity, useActivitiesSelectedActivity } from './hooks';
|
||||||
import { useI18n } from './i18n';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* React component that acts as a tab control to select activities.
|
* React component that renders the panel content for the selected activity.
|
||||||
*/
|
*/
|
||||||
const Activities: React.FunctionComponent = () => {
|
const Activities: React.FunctionComponent = () => {
|
||||||
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
|
const [selectedActivity] = useActivitiesSelectedActivity();
|
||||||
const i18n = useI18n();
|
if (selectedActivity === Activity.Explorer) {
|
||||||
|
return (
|
||||||
|
<div className="pb-activities-tabview">
|
||||||
|
<Explorer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleAction = useCallback(
|
if (selectedActivity === Activity.Settings) {
|
||||||
(newActivity: Activity) => {
|
return (
|
||||||
// if activity is already selected, select none
|
<div className="pb-activities-tabview">
|
||||||
if (selectedActivity === newActivity) {
|
<Settings />
|
||||||
setSelectedActivity(Activity.None);
|
</div>
|
||||||
} else {
|
);
|
||||||
// otherwise select the new activity
|
}
|
||||||
setSelectedActivity(newActivity);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedActivity, setSelectedActivity],
|
|
||||||
);
|
|
||||||
|
|
||||||
// HACK: fix keyboard focus when no tab is selected
|
return null;
|
||||||
|
|
||||||
const tabsRef = useRef<Tabs>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedActivity !== Activity.None) {
|
|
||||||
// all is well
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error: using private property
|
|
||||||
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
|
|
||||||
|
|
||||||
// istanbul-ignore-if: should not happen
|
|
||||||
if (!tablist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstTab = tablist
|
|
||||||
.getElementsByClassName('pb-activities-tablist-tab')
|
|
||||||
.item(0);
|
|
||||||
|
|
||||||
// istanbul-ignore-if: should not happen
|
|
||||||
if (!firstTab) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
firstTab.setAttribute('tabindex', '0');
|
|
||||||
}, [tabsRef, selectedActivity]);
|
|
||||||
|
|
||||||
// HACK: hoist html title attribute from icon to tab
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// @ts-expect-error: using private property
|
|
||||||
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
|
|
||||||
|
|
||||||
// istanbul-ignore-if: should not happen
|
|
||||||
if (!tablist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const element of tablist.getElementsByClassName(
|
|
||||||
'pb-activities-tablist-tab',
|
|
||||||
)) {
|
|
||||||
const title = element.firstElementChild?.getAttribute('title');
|
|
||||||
|
|
||||||
// istanbul-ignore-if: should not happen
|
|
||||||
if (!title) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.setAttribute('title', title);
|
|
||||||
element.firstElementChild?.removeAttribute('title');
|
|
||||||
}
|
|
||||||
}, [tabsRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// @ts-expect-error: using private property
|
|
||||||
const tablist: HTMLDivElement = tabsRef.current?.tablistElement;
|
|
||||||
|
|
||||||
// istanbul-ignore-if: should not happen
|
|
||||||
if (!tablist) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tablist.setAttribute('aria-label', i18n.translate('title'));
|
|
||||||
}, [i18n]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs
|
|
||||||
vertical={true}
|
|
||||||
className="pb-activities"
|
|
||||||
selectedTabId={selectedActivity}
|
|
||||||
renderActiveTabPanelOnly={true}
|
|
||||||
onChange={handleAction}
|
|
||||||
ref={tabsRef}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
itemID="pb-activities-explorer-tab"
|
|
||||||
aria-label={i18n.translate('explorer')}
|
|
||||||
className="pb-activities-tablist-tab"
|
|
||||||
id={Activity.Explorer}
|
|
||||||
title={
|
|
||||||
<Icon
|
|
||||||
htmlTitle={i18n.translate('explorer')}
|
|
||||||
icon={<Document size={35} />}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
panel={<Explorer />}
|
|
||||||
panelClassName="pb-activities-tabview"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Tab
|
|
||||||
itemID="pb-activities-settings-tab"
|
|
||||||
aria-label={i18n.translate('settings')}
|
|
||||||
className="pb-activities-tablist-tab"
|
|
||||||
id={Activity.Settings}
|
|
||||||
title={
|
|
||||||
<Icon
|
|
||||||
htmlTitle={i18n.translate('settings')}
|
|
||||||
icon={<Cog size={35} />}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
panel={<Settings />}
|
|
||||||
panelClassName="pb-activities-tabview"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Activities;
|
export default Activities;
|
||||||
|
|||||||
@@ -1,45 +1,22 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
// Copyright (c) 2022 The Pybricks Authors
|
// Copyright (c) 2022-2026 The Pybricks Authors
|
||||||
|
|
||||||
@use '@blueprintjs/core/lib/scss/variables' as bp;
|
@use '@blueprintjs/core/lib/scss/variables' as bp;
|
||||||
@use '../variables' as pb;
|
@use '../variables' as pb;
|
||||||
|
|
||||||
// override bluetprintjs styles
|
.pb-activities-tabview {
|
||||||
.#{bp.$ns}-tabs.#{bp.$ns}-vertical > {
|
display: flex;
|
||||||
& .#{bp.$ns}-tab-list .pb-activities-tablist-tab {
|
flex-direction: column;
|
||||||
margin: bp.$pt-grid-size * 0.6;
|
width: bp.$pt-grid-size * 25;
|
||||||
padding: unset;
|
padding: bp.$pt-grid-size;
|
||||||
width: unset;
|
@include pb.background-contrast(0%);
|
||||||
line-height: unset;
|
overflow-y: auto;
|
||||||
}
|
height: 100%;
|
||||||
|
|
||||||
& .pb-activities-tabview {
|
// on small screens, make the activity view an overlay instead of inline
|
||||||
padding: bp.$pt-grid-size * 0.5;
|
@media screen and (max-width: pb.$narrow-screen-limit) {
|
||||||
}
|
position: absolute;
|
||||||
}
|
height: calc(100% - pb.$status-bar-height);
|
||||||
|
z-index: bp.$pt-z-index-overlay;
|
||||||
.pb-activities {
|
|
||||||
// TODO: submit upstream patch to allow setting tablist class
|
|
||||||
&-tablist,
|
|
||||||
& .#{bp.$ns}-tab-list {
|
|
||||||
@include pb.background-contrast(6%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&-tabview {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: bp.$pt-grid-size * 25;
|
|
||||||
padding: bp.$pt-grid-size;
|
|
||||||
@include pb.background-contrast(0%);
|
|
||||||
|
|
||||||
// on small screens, make the activity view an overlay instead of inline
|
|
||||||
@media screen and (max-width: pb.$narrow-screen-limit) {
|
|
||||||
position: absolute;
|
|
||||||
top: 0px;
|
|
||||||
// FIXME: this should be a variable
|
|
||||||
left: 47px;
|
|
||||||
height: calc(100% - pb.$status-bar-height);
|
|
||||||
z-index: bp.$pt-z-index-overlay;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ it.each([false, true])('should render', (darkMode) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('documentation pane', () => {
|
describe('documentation pane', () => {
|
||||||
it('should hide by default', () => {
|
it('should be collapsed by default', () => {
|
||||||
testRender(<App />);
|
testRender(<App />);
|
||||||
expect(document.querySelector('.pb-show-docs')).toBeNull();
|
// The docs panel starts collapsed (collapsedSize="0%")
|
||||||
expect(document.querySelector('.pb-hide-docs')).not.toBeNull();
|
const docsPanel = document.querySelector('[data-panel][id="docs"]');
|
||||||
|
expect(docsPanel).not.toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+128
-74
@@ -1,15 +1,16 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
// Copyright (c) 2020-2026 The Pybricks Authors
|
// Copyright (c) 2020-2026 The Pybricks Authors
|
||||||
|
|
||||||
import 'react-splitter-layout/lib/index.css';
|
|
||||||
import './app.scss';
|
import './app.scss';
|
||||||
import { Button, Classes, Spinner } from '@blueprintjs/core';
|
import { Button, Classes, Spinner } from '@blueprintjs/core';
|
||||||
import { Manual } from '@blueprintjs/icons';
|
import { Console, Manual } from '@blueprintjs/icons';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
type SideView = 'off' | 'docs';
|
Panel,
|
||||||
import SplitterLayout from 'react-splitter-layout';
|
Group as PanelGroup,
|
||||||
import { useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
|
Separator as PanelResizeHandle,
|
||||||
|
} from 'react-resizable-panels';
|
||||||
|
import { useTernaryDarkMode } from 'usehooks-ts';
|
||||||
import Activities from '../activities/Activities';
|
import Activities from '../activities/Activities';
|
||||||
import DfuWindowsDriverInstallDialog from '../firmware/dfuWindowsDriverInstallDialog/DfuWindowsDriverInstallDialog';
|
import DfuWindowsDriverInstallDialog from '../firmware/dfuWindowsDriverInstallDialog/DfuWindowsDriverInstallDialog';
|
||||||
import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog';
|
import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog';
|
||||||
@@ -19,6 +20,7 @@ import StatusBar from '../status-bar/StatusBar';
|
|||||||
import Toolbar from '../toolbar/Toolbar';
|
import Toolbar from '../toolbar/Toolbar';
|
||||||
import Tour from '../tour/Tour';
|
import Tour from '../tour/Tour';
|
||||||
import { docsDefaultPage } from './constants';
|
import { docsDefaultPage } from './constants';
|
||||||
|
import { useCollapsiblePanel } from './hooks';
|
||||||
import { useI18n } from './i18n';
|
import { useI18n } from './i18n';
|
||||||
|
|
||||||
const Editor = React.lazy(async () => {
|
const Editor = React.lazy(async () => {
|
||||||
@@ -73,11 +75,9 @@ const Docs: React.FunctionComponent = () => {
|
|||||||
const App: React.FunctionComponent = () => {
|
const App: React.FunctionComponent = () => {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { isDarkMode } = useTernaryDarkMode();
|
const { isDarkMode } = useTernaryDarkMode();
|
||||||
const [sideView, setSideView] = useState<SideView>('off');
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
|
|
||||||
const [docsSplit, setDocsSplit] = useLocalStorage('app-docs-split', 30);
|
const docs = useCollapsiblePanel(false, 30);
|
||||||
const [terminalSplit, setTerminalSplit] = useLocalStorage('app-terminal-split', 30);
|
const terminal = useCollapsiblePanel(true, 20);
|
||||||
|
|
||||||
// Classes.DARK has to be applied to body element, otherwise it won't
|
// Classes.DARK has to be applied to body element, otherwise it won't
|
||||||
// affect portals
|
// affect portals
|
||||||
@@ -105,80 +105,134 @@ const App: React.FunctionComponent = () => {
|
|||||||
return () => removeEventListener('keydown', listener);
|
return () => removeEventListener('keydown', listener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const sideViewButtons = (
|
||||||
|
<div className="pb-app-side-view-buttons">
|
||||||
|
<Button
|
||||||
|
large
|
||||||
|
intent="primary"
|
||||||
|
icon={<Console />}
|
||||||
|
title={
|
||||||
|
terminal.visible
|
||||||
|
? i18n.translate('terminal.hide')
|
||||||
|
: i18n.translate('terminal.show')
|
||||||
|
}
|
||||||
|
onClick={() => terminal.setVisible(!terminal.visible)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
large
|
||||||
|
intent="primary"
|
||||||
|
icon={<Manual />}
|
||||||
|
title={
|
||||||
|
docs.visible
|
||||||
|
? i18n.translate('docs.hide')
|
||||||
|
: i18n.translate('docs.show')
|
||||||
|
}
|
||||||
|
onClick={() => docs.setVisible(!docs.visible)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-app" onContextMenu={(e) => e.preventDefault()}>
|
<div className="pb-app" onContextMenu={(e) => e.preventDefault()}>
|
||||||
<div className="pb-app-body">
|
<div className="pb-app-body">
|
||||||
<aside
|
<PanelGroup
|
||||||
className="pb-app-activities"
|
orientation="horizontal"
|
||||||
aria-label={i18n.translate('landmark.activities')}
|
resizeTargetMinimumSize={{ coarse: 37, fine: 26 }}
|
||||||
>
|
>
|
||||||
<Activities />
|
<Panel id="main-app">
|
||||||
</aside>
|
<div className="pb-app-main">
|
||||||
{/* need a container with position: relative; for SplitterLayout since it uses position: absolute; */}
|
<Toolbar />
|
||||||
<div className="pb-app-main" style={{ position: 'relative' }}>
|
<div className="pb-app-body-inner">
|
||||||
<SplitterLayout
|
<aside
|
||||||
customClassName={
|
className="pb-app-activities"
|
||||||
sideView === 'docs' ? 'pb-show-docs' : 'pb-hide-docs'
|
aria-label={i18n.translate('landmark.activities')}
|
||||||
|
>
|
||||||
|
<Activities />
|
||||||
|
</aside>
|
||||||
|
<PanelGroup
|
||||||
|
orientation="vertical"
|
||||||
|
resizeTargetMinimumSize={{ coarse: 37, fine: 26 }}
|
||||||
|
>
|
||||||
|
<Panel>
|
||||||
|
<div className="pb-app-editor-area">
|
||||||
|
<PanelGroup orientation="horizontal">
|
||||||
|
<Panel>
|
||||||
|
<main
|
||||||
|
className="pb-app-editor"
|
||||||
|
aria-label={i18n.translate(
|
||||||
|
'landmark.editor',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<React.Suspense
|
||||||
|
fallback={
|
||||||
|
<Spinner className="pb-editor" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Editor />
|
||||||
|
</React.Suspense>
|
||||||
|
</main>
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
{sideViewButtons}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle
|
||||||
|
disabled={!terminal.visible}
|
||||||
|
className={
|
||||||
|
terminal.visible
|
||||||
|
? 'pb-splitter pb-splitter--horizontal'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
id="terminal"
|
||||||
|
collapsible
|
||||||
|
collapsedSize="0%"
|
||||||
|
minSize="10%"
|
||||||
|
panelRef={terminal.panelRef}
|
||||||
|
onResize={terminal.onResize}
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
className="pb-app-terminal"
|
||||||
|
aria-label={i18n.translate(
|
||||||
|
'landmark.terminal',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<React.Suspense
|
||||||
|
fallback={<Spinner className="h-100" />}
|
||||||
|
>
|
||||||
|
<Terminal />
|
||||||
|
</React.Suspense>
|
||||||
|
</aside>
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle
|
||||||
|
disabled={!docs.visible}
|
||||||
|
className={
|
||||||
|
docs.visible
|
||||||
|
? 'pb-splitter pb-splitter--vertical'
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
onDragStart={(): void => setIsDragging(true)}
|
/>
|
||||||
onDragEnd={(): void => setIsDragging(false)}
|
<Panel
|
||||||
percentage={true}
|
id="docs"
|
||||||
secondaryInitialSize={docsSplit}
|
collapsible
|
||||||
onSecondaryPaneSizeChange={setDocsSplit}
|
collapsedSize="0%"
|
||||||
|
minSize="10%"
|
||||||
|
panelRef={docs.panelRef}
|
||||||
|
onResize={docs.onResize}
|
||||||
>
|
>
|
||||||
<SplitterLayout
|
|
||||||
vertical={true}
|
|
||||||
percentage={true}
|
|
||||||
secondaryInitialSize={terminalSplit}
|
|
||||||
onSecondaryPaneSizeChange={setTerminalSplit}
|
|
||||||
>
|
|
||||||
<main
|
|
||||||
className="pb-app-editor"
|
|
||||||
aria-label={i18n.translate('landmark.editor')}
|
|
||||||
>
|
|
||||||
<Toolbar />
|
|
||||||
<React.Suspense
|
|
||||||
fallback={<Spinner className="pb-editor" />}
|
|
||||||
>
|
|
||||||
<Editor />
|
|
||||||
</React.Suspense>
|
|
||||||
<Button
|
|
||||||
className="pb-app-doc-button"
|
|
||||||
minimal
|
|
||||||
large
|
|
||||||
icon={<Manual />}
|
|
||||||
title={
|
|
||||||
sideView === 'docs'
|
|
||||||
? i18n.translate('docs.hide')
|
|
||||||
: i18n.translate('docs.show')
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
setSideView(
|
|
||||||
sideView === 'docs' ? 'off' : 'docs',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
<aside
|
|
||||||
className="pb-app-terminal"
|
|
||||||
aria-label={i18n.translate('landmark.terminal')}
|
|
||||||
>
|
|
||||||
<React.Suspense
|
|
||||||
fallback={<Spinner className="h-100" />}
|
|
||||||
>
|
|
||||||
<Terminal />
|
|
||||||
</React.Suspense>
|
|
||||||
</aside>
|
|
||||||
</SplitterLayout>
|
|
||||||
<aside
|
<aside
|
||||||
className="pb-app-docs"
|
className="pb-app-side-panel"
|
||||||
aria-label={i18n.translate('landmark.documentation')}
|
aria-label={i18n.translate('landmark.documentation')}
|
||||||
>
|
>
|
||||||
{isDragging && <div className="pb-app-docs-drag-helper" />}
|
|
||||||
<Docs />
|
<Docs />
|
||||||
</aside>
|
</aside>
|
||||||
</SplitterLayout>
|
</Panel>
|
||||||
</div>
|
</PanelGroup>
|
||||||
</div>
|
</div>
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<Tour />
|
<Tour />
|
||||||
|
|||||||
+149
-119
@@ -12,194 +12,224 @@
|
|||||||
|
|
||||||
@import '@blueprintjs/icons/lib/css/blueprint-icons.css';
|
@import '@blueprintjs/icons/lib/css/blueprint-icons.css';
|
||||||
|
|
||||||
|
// Root app container: fills the viewport, stacks toolbar/body/statusbar vertically.
|
||||||
.pb-app {
|
.pb-app {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: bp.$pt-app-background-color;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: bp.$pt-app-background-color;
|
||||||
|
|
||||||
.#{bp.$ns}-dark & {
|
.#{bp.$ns}-dark & {
|
||||||
background-color: bp.$pt-dark-app-background-color;
|
background-color: bp.$pt-dark-app-background-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-activities {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-terminal {
|
|
||||||
height: 100%;
|
|
||||||
padding-top: 8px;
|
|
||||||
padding-left: 8px;
|
|
||||||
background-color: white;
|
|
||||||
|
|
||||||
.#{bp.$ns}-dark & {
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-docs {
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
&-drag-helper {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& iframe {
|
& iframe {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row containing the activities sidebar and the main content area.
|
||||||
.pb-app-body {
|
.pb-app-body {
|
||||||
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
.pb-app-main {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
@include pb.background-contrast(4%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-app-editor {
|
// Thin activities sidebar on the left, shown when an activity is selected.
|
||||||
|
.pb-app-activities {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
@include pb.background-contrast(0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row containing the activities panel and the resizable editor/terminal panels.
|
||||||
|
.pb-app-body-inner {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content: toolbar on top, resizable panels below. Fills its Panel.
|
||||||
|
.pb-app-main {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
overflow: hidden;
|
||||||
|
@include pb.background-contrast(4%);
|
||||||
|
}
|
||||||
|
|
||||||
& .pb-editor {
|
// Wraps the horizontal editor+camera PanelGroup. position: relative anchors
|
||||||
min-height: 0;
|
// the absolutely-positioned side-view buttons to this container instead of
|
||||||
flex: 1 1 auto;
|
// the editor panel, so they stay visible even when the camera is expanded.
|
||||||
|
.pb-app-editor-area {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The editor <main> element lives inside a resizable Panel - fill it entirely.
|
||||||
|
.pb-app-editor {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal panel fills its resizable panel.
|
||||||
|
.pb-app-terminal {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: white;
|
||||||
|
|
||||||
|
.#{bp.$ns}-dark & {
|
||||||
|
background-color: black;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pb-app-doc-button {
|
// Side panel fills its resizable panel.
|
||||||
|
.pb-app-side-panel {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pb-app-side-view-buttons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: bp.$pt-grid-size;
|
bottom: bp.$pt-grid-size;
|
||||||
right: bp.$pt-grid-size;
|
right: bp.$pt-grid-size;
|
||||||
z-index: bp.$pt-z-index-overlay;
|
z-index: bp.$pt-z-index-content;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: bp.$pt-grid-size * 0.5;
|
||||||
|
|
||||||
|
// match toolbar button appearance: no box-shadow border or active-state shadow
|
||||||
|
& * {
|
||||||
|
box-shadow: unset !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splitter color variables
|
||||||
$splitter-background-color: bp.$light-gray2;
|
$splitter-background-color: bp.$light-gray2;
|
||||||
$splitter-background-color-hover: bp.$gray4;
|
$splitter-background-color-hover: bp.$gray4;
|
||||||
$dark-splitter-background-color: bp.$dark-gray5;
|
$dark-splitter-background-color: bp.$dark-gray5;
|
||||||
$dark-splitter-background-color-hover: bp.$gray2;
|
$dark-splitter-background-color-hover: bp.$gray2;
|
||||||
|
|
||||||
$splitter-constrast: -15%;
|
$splitter-contrast: -15%;
|
||||||
$dark-splitter-constrast: 20%;
|
$dark-splitter-contrast: 20%;
|
||||||
|
|
||||||
$splitter-color: color.adjust(
|
$splitter-color: color.adjust(
|
||||||
$splitter-background-color,
|
$splitter-background-color,
|
||||||
$lightness: $splitter-constrast
|
$lightness: $splitter-contrast
|
||||||
);
|
);
|
||||||
$splitter-color-hover: color.adjust(
|
$splitter-color-hover: color.adjust(
|
||||||
$splitter-background-color-hover,
|
$splitter-background-color-hover,
|
||||||
$lightness: $splitter-constrast
|
$lightness: $splitter-contrast
|
||||||
);
|
);
|
||||||
$dark-splitter-color: color.adjust(
|
$dark-splitter-color: color.adjust(
|
||||||
$dark-splitter-background-color,
|
$dark-splitter-background-color,
|
||||||
$lightness: $dark-splitter-constrast
|
$lightness: $dark-splitter-contrast
|
||||||
);
|
);
|
||||||
$dark-splitter-color-hover: color.adjust(
|
$dark-splitter-color-hover: color.adjust(
|
||||||
$dark-splitter-background-color-hover,
|
$dark-splitter-background-color-hover,
|
||||||
$lightness: $dark-splitter-constrast
|
$lightness: $dark-splitter-contrast
|
||||||
);
|
);
|
||||||
|
|
||||||
.layout-splitter:before {
|
// Panel resize handles (splitters).
|
||||||
$width: 1.5;
|
.pb-splitter {
|
||||||
$height: 2.7;
|
|
||||||
$radius: 0.4;
|
|
||||||
|
|
||||||
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') / '';
|
|
||||||
height: bp.$pt-grid-size * $height;
|
|
||||||
width: bp.$pt-grid-size * $width;
|
|
||||||
top: calc(50% - bp.$pt-grid-size * $height * 0.5);
|
|
||||||
left: bp.$pt-grid-size * $width * -0.5 + 2px;
|
|
||||||
|
|
||||||
.splitter-layout-vertical & {
|
|
||||||
$width: 2.7;
|
|
||||||
$height: 1.4;
|
|
||||||
|
|
||||||
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') / '';
|
|
||||||
height: bp.$pt-grid-size * $height;
|
|
||||||
width: bp.$pt-grid-size * $width;
|
|
||||||
top: bp.$pt-grid-size * $height * -0.5 + 2px;
|
|
||||||
left: calc(50% - bp.$pt-grid-size * $width * 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
font-family: bpi.$blueprint-icons-16-font;
|
|
||||||
font-size: large;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
|
||||||
border-radius: bp.$pt-grid-size * $radius;
|
|
||||||
padding: bp.$pt-grid-size * 0.5;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: bp.$pt-z-index-content;
|
|
||||||
|
|
||||||
color: $splitter-color;
|
|
||||||
background-color: $splitter-background-color;
|
background-color: $splitter-background-color;
|
||||||
|
|
||||||
.#{bp.$ns}-dark & {
|
&:hover {
|
||||||
color: $dark-splitter-color;
|
|
||||||
background-color: $dark-splitter-background-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// make layout splitter match app color scheme
|
|
||||||
.splitter-layout > .layout-splitter {
|
|
||||||
color: $splitter-color;
|
|
||||||
background-color: $splitter-background-color;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:hover:before {
|
|
||||||
color: $splitter-color-hover;
|
|
||||||
background-color: $splitter-background-color-hover;
|
background-color: $splitter-background-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.#{bp.$ns}-dark & {
|
.#{bp.$ns}-dark & {
|
||||||
color: $dark-splitter-color;
|
|
||||||
background-color: $dark-splitter-background-color;
|
background-color: $dark-splitter-background-color;
|
||||||
|
|
||||||
&:hover,
|
&:hover {
|
||||||
&:hover:before {
|
background-color: $dark-splitter-background-color-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-handle icon via ::before pseudo-element.
|
||||||
|
&::before {
|
||||||
|
$radius: 0.4;
|
||||||
|
|
||||||
|
font-family: bpi.$blueprint-icons-16-font;
|
||||||
|
font-size: large;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: bp.$pt-grid-size * $radius;
|
||||||
|
padding: bp.$pt-grid-size * 0.5;
|
||||||
|
z-index: bp.$pt-z-index-content;
|
||||||
|
|
||||||
|
color: $splitter-color;
|
||||||
|
background-color: $splitter-background-color;
|
||||||
|
|
||||||
|
.#{bp.$ns}-dark & {
|
||||||
|
color: $dark-splitter-color;
|
||||||
|
background-color: $dark-splitter-background-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::before {
|
||||||
|
color: $splitter-color-hover;
|
||||||
|
background-color: $splitter-background-color-hover;
|
||||||
|
|
||||||
|
.#{bp.$ns}-dark & {
|
||||||
color: $dark-splitter-color-hover;
|
color: $dark-splitter-color-hover;
|
||||||
background-color: $dark-splitter-background-color-hover;
|
background-color: $dark-splitter-background-color-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// we want overflow visible for editor overlays but it breaks resizing
|
// Vertical splitter (between left/right panels): thin column, vertical drag icon.
|
||||||
// so we can only apply this when not resizing
|
&--vertical {
|
||||||
.splitter-layout:not(.layout-changing) > .layout-pane:has(.pb-app-editor) {
|
width: 4px;
|
||||||
overflow: visible;
|
cursor: col-resize;
|
||||||
}
|
|
||||||
|
|
||||||
.splitter-layout .layout-pane {
|
&::before {
|
||||||
// fix double scroll bars in terminal when resizing
|
$width: 1.5;
|
||||||
overflow: hidden;
|
$height: 2.7;
|
||||||
|
|
||||||
&.layout-pane-primary {
|
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-vertical') /
|
||||||
// fix resizing window vertically
|
'';
|
||||||
min-height: 0;
|
width: bp.$pt-grid-size * $width;
|
||||||
|
height: bp.$pt-grid-size * $height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal splitter (between top/bottom panels): thin row, horizontal drag icon.
|
||||||
|
&--horizontal {
|
||||||
|
height: 4px;
|
||||||
|
cursor: row-resize;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
$width: 2.7;
|
||||||
|
$height: 1.4;
|
||||||
|
|
||||||
|
content: map.get(bpi.$blueprint-icon-codepoints, 'drag-handle-horizontal') /
|
||||||
|
'';
|
||||||
|
width: bp.$pt-grid-size * $width;
|
||||||
|
height: bp.$pt-grid-size * $height;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hide the docs and resize separator
|
|
||||||
// Use visibility rather than display:none so the iframe keeps its layout and
|
|
||||||
// scroll position when the docs panel is toggled.
|
|
||||||
// Collapse the secondary pane and splitter to zero width so they don't
|
|
||||||
// consume space, while keeping the iframe in the DOM.
|
|
||||||
div.pb-hide-docs > :not(.layout-pane-primary) {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
flex-basis: 0 !important;
|
|
||||||
width: 0 !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2026 The Pybricks Authors
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { PanelSize, usePanelRef } from 'react-resizable-panels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage a collapsible panel's size as a percentage (0–100).
|
||||||
|
*
|
||||||
|
* This bridges React's declarative state with the library's imperative
|
||||||
|
* collapse/expand/resize API:
|
||||||
|
* - `size` tracks the panel's percentage when visible.
|
||||||
|
* - `visible` controls whether the panel is collapsed or expanded.
|
||||||
|
* - Setting `visible` to true restores the previous `size`.
|
||||||
|
* - Setting `size` also updates `visible` accordingly.
|
||||||
|
* - The `onResize` callback syncs both states when the user drags.
|
||||||
|
*
|
||||||
|
* The panel should set `collapsible`, `collapsedSize="0%"`, and a `minSize`
|
||||||
|
* so that dragging below the minimum auto-collapses.
|
||||||
|
*/
|
||||||
|
export function useCollapsiblePanel(initiallyVisble: boolean, initialSize: number) {
|
||||||
|
const panelRef = usePanelRef();
|
||||||
|
const [size, _setSize] = useState(initialSize);
|
||||||
|
const [visible, _setVisible] = useState(initiallyVisble);
|
||||||
|
|
||||||
|
const setSize = useCallback((s: number) => {
|
||||||
|
_setSize(s);
|
||||||
|
_setVisible(s > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVisible = useCallback((v: boolean) => _setVisible(v), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!visible) {
|
||||||
|
if (!panel.isCollapsed()) {
|
||||||
|
panel.collapse();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (panel.isCollapsed()) {
|
||||||
|
panel.expand();
|
||||||
|
}
|
||||||
|
if (panel.getSize().asPercentage !== size) {
|
||||||
|
panel.resize(size + '%');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [size, visible, panelRef]);
|
||||||
|
|
||||||
|
const onResize = useCallback(
|
||||||
|
(panelSize: PanelSize) => {
|
||||||
|
const collapsed = panelRef.current?.isCollapsed() ?? false;
|
||||||
|
if (collapsed) {
|
||||||
|
// Only update state if the user dragged to collapse
|
||||||
|
// (visible is still true). If we collapsed programmatically,
|
||||||
|
// visible is already false and size should be preserved.
|
||||||
|
if (visible) {
|
||||||
|
_setVisible(false);
|
||||||
|
// Reset to initial size so reopening looks normal
|
||||||
|
// rather than tiny.
|
||||||
|
_setSize(initialSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_setVisible(true);
|
||||||
|
_setSize(panelSize.asPercentage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panelRef, initialSize, visible],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({ panelRef, size, setSize, visible, setVisible, onResize }),
|
||||||
|
[panelRef, size, setSize, visible, setVisible, onResize],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,5 +8,9 @@
|
|||||||
"docs": {
|
"docs": {
|
||||||
"show": "Show documentation",
|
"show": "Show documentation",
|
||||||
"hide": "Hide documentation"
|
"hide": "Hide documentation"
|
||||||
|
},
|
||||||
|
"terminal": {
|
||||||
|
"show": "Show terminal",
|
||||||
|
"hide": "Hide terminal"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-6
@@ -1,5 +1,5 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
// Copyright (c) 2020-2023 The Pybricks Authors
|
// Copyright (c) 2020-2026 The Pybricks Authors
|
||||||
|
|
||||||
// Custom styling for the Editor control.
|
// Custom styling for the Editor control.
|
||||||
|
|
||||||
@@ -21,8 +21,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pb-editor {
|
.pb-editor {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&-tablist {
|
&-tablist {
|
||||||
flex: none;
|
flex: none;
|
||||||
@@ -58,9 +62,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ContextMenu/tabpanel fills all remaining vertical space.
|
||||||
&-tabpanel {
|
&-tabpanel {
|
||||||
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-welcome {
|
&-welcome {
|
||||||
@@ -73,13 +80,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The monaco editor div fills the tabpanel entirely.
|
||||||
&-monaco {
|
&-monaco {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.pb-editor-tabpanel.pb-empty > & {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&-placeholder {
|
&-placeholder {
|
||||||
|
|||||||
@@ -38,6 +38,43 @@ if (!Element.prototype.scrollTo) {
|
|||||||
Element.prototype.scrollTo = jest.fn();
|
Element.prototype.scrollTo = jest.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResizeObserver is not implemented in jsdom but is required by react-resizable-panels
|
||||||
|
global.ResizeObserver = class ResizeObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOMRect is not implemented in jsdom but is required by react-resizable-panels
|
||||||
|
if (typeof global.DOMRect === 'undefined') {
|
||||||
|
global.DOMRect = class DOMRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||||
|
this.x = x;
|
||||||
|
this.y = y;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.top = y;
|
||||||
|
this.right = x + width;
|
||||||
|
this.bottom = y + height;
|
||||||
|
this.left = x;
|
||||||
|
}
|
||||||
|
toJSON() {
|
||||||
|
return JSON.stringify(this);
|
||||||
|
}
|
||||||
|
static fromRect(rect?: DOMRectInit): DOMRect {
|
||||||
|
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
|
||||||
|
}
|
||||||
|
} as unknown as typeof DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
Object.defineProperty(global.self, 'crypto', {
|
Object.defineProperty(global.self, 'crypto', {
|
||||||
value: crypto.webcrypto,
|
value: crypto.webcrypto,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
// Copyright (c) 2020-2025 The Pybricks Authors
|
// Copyright (c) 2020-2026 The Pybricks Authors
|
||||||
|
|
||||||
import { ButtonGroup } from '@blueprintjs/core';
|
import { ButtonGroup } from '@blueprintjs/core';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useId } from 'react-aria';
|
import { useId } from 'react-aria';
|
||||||
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
|
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
|
||||||
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
|
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
|
||||||
|
import ExplorerButton from './buttons/explorer/ExplorerButton';
|
||||||
import ReplButton from './buttons/repl/ReplButton';
|
import ReplButton from './buttons/repl/ReplButton';
|
||||||
import RunButton from './buttons/run/RunButton';
|
import RunButton from './buttons/run/RunButton';
|
||||||
|
import SettingsButton from './buttons/settings/SettingsButton';
|
||||||
import SponsorButton from './buttons/sponsor/SponsorButton';
|
import SponsorButton from './buttons/sponsor/SponsorButton';
|
||||||
import StopButton from './buttons/stop/StopButton';
|
import StopButton from './buttons/stop/StopButton';
|
||||||
import UsbButton from './buttons/usb/UsbButton';
|
import UsbButton from './buttons/usb/UsbButton';
|
||||||
@@ -32,6 +34,10 @@ const Toolbar: React.FunctionComponent = () => {
|
|||||||
className="pb-toolbar"
|
className="pb-toolbar"
|
||||||
firstFocusableItemId={bluetoothButtonId}
|
firstFocusableItemId={bluetoothButtonId}
|
||||||
>
|
>
|
||||||
|
<ButtonGroup className="pb-toolbar-group pb-align-left">
|
||||||
|
<ExplorerButton id="pb-toolbar-explorer-button" />
|
||||||
|
<SettingsButton id="pb-toolbar-settings-button" />
|
||||||
|
</ButtonGroup>
|
||||||
<ButtonGroup className="pb-toolbar-group pb-align-left">
|
<ButtonGroup className="pb-toolbar-group pb-align-left">
|
||||||
<UsbButton id={usbButtonId} />
|
<UsbButton id={usbButtonId} />
|
||||||
<BluetoothButton id={bluetoothButtonId} />
|
<BluetoothButton id={bluetoothButtonId} />
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 The Pybricks Authors
|
||||||
|
|
||||||
|
import { act, cleanup } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { FocusScope } from 'react-aria';
|
||||||
|
import { testRender } from '../../../../test';
|
||||||
|
import { Activity } from '../../../activities/hooks';
|
||||||
|
import ExplorerButton from './ExplorerButton';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.resetAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const explorerButtonId = 'test-explorer-button';
|
||||||
|
|
||||||
|
it('should open explorer activity when clicked', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'activities.selectedActivity',
|
||||||
|
JSON.stringify(Activity.None),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [user, button] = testRender(
|
||||||
|
<FocusScope>
|
||||||
|
<ExplorerButton id={explorerButtonId} />
|
||||||
|
</FocusScope>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
|
||||||
|
JSON.stringify(Activity.Explorer),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close explorer activity when clicked while already active', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'activities.selectedActivity',
|
||||||
|
JSON.stringify(Activity.Explorer),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [user, button] = testRender(
|
||||||
|
<FocusScope>
|
||||||
|
<ExplorerButton id={explorerButtonId} />
|
||||||
|
</FocusScope>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => user.click(button.getByRole('button', { name: 'File Explorer' })));
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
|
||||||
|
JSON.stringify(Activity.None),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2026 The Pybricks Authors
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
|
||||||
|
import ActionButton, { ActionButtonProps } from '../../ActionButton';
|
||||||
|
import { useI18n } from './i18n';
|
||||||
|
import icon from './icon.svg';
|
||||||
|
|
||||||
|
type ExplorerButtonProps = Pick<ActionButtonProps, 'id'>;
|
||||||
|
|
||||||
|
const ExplorerButton: React.FunctionComponent<ExplorerButtonProps> = ({ id }) => {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
id={id}
|
||||||
|
label={i18n.translate('label')}
|
||||||
|
tooltip={i18n.translate(
|
||||||
|
selectedActivity === Activity.Explorer
|
||||||
|
? 'tooltip.hide'
|
||||||
|
: 'tooltip.show',
|
||||||
|
)}
|
||||||
|
icon={icon}
|
||||||
|
onAction={() =>
|
||||||
|
setSelectedActivity(
|
||||||
|
selectedActivity === Activity.Explorer
|
||||||
|
? Activity.None
|
||||||
|
: Activity.Explorer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExplorerButton;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 The Pybricks Authors
|
||||||
|
|
||||||
|
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||||
|
import type { TypedI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
type Translations = {
|
||||||
|
label: string;
|
||||||
|
tooltip: {
|
||||||
|
show: string;
|
||||||
|
hide: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useI18n(): TypedI18n<Translations> {
|
||||||
|
// istanbul ignore next: babel-loader rewrites this line
|
||||||
|
const [i18n] = useShopifyI18n();
|
||||||
|
return i18n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m6.0542 2.1166c-0.71568 0-1.2917 0.55109-1.2917 1.2353v19.931h16.933v-14.679l-6.7462-6.4875zm0.92953 2.2985h6.8636v5.3885h5.6001v11.769h-12.464v-11.769z" fill="#fff" fill-opacity=".98215" stop-color="#000000" stroke-linecap="square" stroke-width="5.9999"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"label": "File Explorer",
|
||||||
|
"tooltip": {
|
||||||
|
"show": "Show file explorer",
|
||||||
|
"hide": "Hide file explorer"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 The Pybricks Authors
|
||||||
|
|
||||||
|
import { act, cleanup } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import { FocusScope } from 'react-aria';
|
||||||
|
import { testRender } from '../../../../test';
|
||||||
|
import { Activity } from '../../../activities/hooks';
|
||||||
|
import SettingsButton from './SettingsButton';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.resetAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsButtonId = 'test-settings-button';
|
||||||
|
|
||||||
|
it('should open settings activity when clicked', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'activities.selectedActivity',
|
||||||
|
JSON.stringify(Activity.None),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [user, button] = testRender(
|
||||||
|
<FocusScope>
|
||||||
|
<SettingsButton id={settingsButtonId} />
|
||||||
|
</FocusScope>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
|
||||||
|
JSON.stringify(Activity.Settings),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close settings activity when clicked while already active', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'activities.selectedActivity',
|
||||||
|
JSON.stringify(Activity.Settings),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [user, button] = testRender(
|
||||||
|
<FocusScope>
|
||||||
|
<SettingsButton id={settingsButtonId} />
|
||||||
|
</FocusScope>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => user.click(button.getByRole('button', { name: 'Settings' })));
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('activities.selectedActivity')).toBe(
|
||||||
|
JSON.stringify(Activity.None),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2026 The Pybricks Authors
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Activity, useActivitiesSelectedActivity } from '../../../activities/hooks';
|
||||||
|
import ActionButton, { ActionButtonProps } from '../../ActionButton';
|
||||||
|
import { useI18n } from './i18n';
|
||||||
|
import icon from './icon.svg';
|
||||||
|
|
||||||
|
type SettingsButtonProps = Pick<ActionButtonProps, 'id'>;
|
||||||
|
|
||||||
|
const SettingsButton: React.FunctionComponent<SettingsButtonProps> = ({ id }) => {
|
||||||
|
const i18n = useI18n();
|
||||||
|
const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
id={id}
|
||||||
|
label={i18n.translate('label')}
|
||||||
|
tooltip={i18n.translate(
|
||||||
|
selectedActivity === Activity.Settings
|
||||||
|
? 'tooltip.hide'
|
||||||
|
: 'tooltip.show',
|
||||||
|
)}
|
||||||
|
icon={icon}
|
||||||
|
onAction={() =>
|
||||||
|
setSelectedActivity(
|
||||||
|
selectedActivity === Activity.Settings
|
||||||
|
? Activity.None
|
||||||
|
: Activity.Settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsButton;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// Copyright (c) 2025 The Pybricks Authors
|
||||||
|
|
||||||
|
import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||||
|
import type { TypedI18n } from '../../../i18n';
|
||||||
|
|
||||||
|
type Translations = {
|
||||||
|
label: string;
|
||||||
|
tooltip: {
|
||||||
|
show: string;
|
||||||
|
hide: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useI18n(): TypedI18n<Translations> {
|
||||||
|
// istanbul ignore next: babel-loader rewrites this line
|
||||||
|
const [i18n] = useShopifyI18n();
|
||||||
|
return i18n;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m12.061 3.175c-0.52433 0-0.94647 0.42215-0.94647 0.94649v2.1893a7.1944 7.1946 0 0 0-1.3064 0.50605l-1.5234-1.5234c-0.37076-0.37076-0.96782-0.37076-1.3386 0l-1.6522 1.6526c-0.37076 0.37076-0.37076 0.96784 0 1.3386l1.4626 1.4626a7.1944 7.1946 0 0 0-0.58294 1.3672h-2.052c-0.52433 0-0.94647 0.42175-0.94647 0.94608v2.337c0 0.52434 0.42214 0.94649 0.94647 0.94649h2.0503a7.1944 7.1946 0 0 0 0.58253 1.3688l-1.4606 1.461c-0.37076 0.37076-0.37076 0.96785 0 1.3386l1.6522 1.6522c0.37076 0.37076 0.96782 0.37076 1.3386 0l1.5197-1.5198a7.1944 7.1946 0 0 0 1.31 0.50767v2.184c0 0.52434 0.42214 0.94649 0.94647 0.94648h2.3366c0.52433 0 0.94647-0.42214 0.94647-0.94648v-2.2919a7.1944 7.1946 0 0 0 1.1848-0.52498l1.6449 1.645c0.37076 0.37076 0.96782 0.37076 1.3386 0l1.6522-1.6522c0.37076-0.37076 0.37076-0.96784 0-1.3386l-1.7057-1.7058a7.1944 7.1946 0 0 0 0.45733-1.124h2.4207c0.52433 0 0.94647-0.42215 0.94647-0.94649v-2.337c0-0.52433-0.42214-0.94608-0.94647-0.94608h-2.4179a7.1944 7.1946 0 0 0-0.45492-1.1293l1.7005-1.7005c0.37076-0.37076 0.37076-0.96784 0-1.3386l-1.6522-1.6526c-0.37076-0.37076-0.96782-0.37076-1.3386 0l-1.6445 1.6446a7.1944 7.1946 0 0 0-1.1852-0.52296v-2.2935c0-0.52434-0.42214-0.94649-0.94647-0.94649zm1.1683 5.8279a4.226 4.226 0 0 1 4.2259 4.226 4.226 4.226 0 0 1-4.2259 4.2264 4.226 4.226 0 0 1-4.2259-4.2264 4.226 4.226 0 0 1 4.2259-4.226z" fill="#fff" stop-color="#000000" stroke-linecap="square" stroke-width="5.9999"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"label": "Settings",
|
||||||
|
"tooltip": {
|
||||||
|
"show": "Show settings and help",
|
||||||
|
"hide": "Hide settings and help"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2994,7 +2994,6 @@ __metadata:
|
|||||||
"@types/node": ^18.19.14
|
"@types/node": ^18.19.14
|
||||||
"@types/react": ^18.2.47
|
"@types/react": ^18.2.47
|
||||||
"@types/react-dom": ^18.2.18
|
"@types/react-dom": ^18.2.18
|
||||||
"@types/react-splitter-layout": ^3.0.5
|
|
||||||
"@types/react-transition-group": ^4.4.10
|
"@types/react-transition-group": ^4.4.10
|
||||||
"@types/redux-logger": ^3.0.12
|
"@types/redux-logger": ^3.0.12
|
||||||
"@types/semver": ^7.5.6
|
"@types/semver": ^7.5.6
|
||||||
@@ -3075,7 +3074,7 @@ __metadata:
|
|||||||
react-popper: ^2.3.0
|
react-popper: ^2.3.0
|
||||||
react-redux: ^8.1.3
|
react-redux: ^8.1.3
|
||||||
react-refresh: ^0.14.0
|
react-refresh: ^0.14.0
|
||||||
react-splitter-layout: ^4.0.0
|
react-resizable-panels: ^4.8.0
|
||||||
redux: ^4.2.1
|
redux: ^4.2.1
|
||||||
redux-logger: ^3.0.6
|
redux-logger: ^3.0.6
|
||||||
redux-saga: ^1.3.0
|
redux-saga: ^1.3.0
|
||||||
@@ -5341,15 +5340,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/react-splitter-layout@npm:^3.0.5":
|
|
||||||
version: 3.0.5
|
|
||||||
resolution: "@types/react-splitter-layout@npm:3.0.5"
|
|
||||||
dependencies:
|
|
||||||
"@types/react": "*"
|
|
||||||
checksum: 9c8e9cc7ac568a3f5c7a1237725ce084d4c0747f91f7da359b9d5bf2439b33c99f3b90223443854d9602af84a1fad1ffe159ac0a2b77d4a42588e17044c136ec
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"@types/react-transition-group@npm:^4.4.10":
|
"@types/react-transition-group@npm:^4.4.10":
|
||||||
version: 4.4.10
|
version: 4.4.10
|
||||||
resolution: "@types/react-transition-group@npm:4.4.10"
|
resolution: "@types/react-transition-group@npm:4.4.10"
|
||||||
@@ -14778,13 +14768,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-splitter-layout@npm:^4.0.0":
|
"react-resizable-panels@npm:^4.8.0":
|
||||||
version: 4.0.0
|
version: 4.8.0
|
||||||
resolution: "react-splitter-layout@npm:4.0.0"
|
resolution: "react-resizable-panels@npm:4.8.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
prop-types: ^15.5.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react: ^15.5.0 || ^16.0.0
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
checksum: 52fa6ec01f18df0938687089d85cfdf1950f04c7b611d973edc6fc6b553d1844b414c6d92e21e7de8043a29b21faca9a31b87457328bca7c424493db2c866700
|
checksum: 387f54de46e0727539912878279046816374438f08f0ca2c31ded71a40a65bc68cbab592ba7410469737f3ae120a7c973caebebd19d9dc65ae29019e94c8b93c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user