drag and drop support for tabs (#146)

* back-end implementation. initial ui implementation

* rough implementation of drag and drop

* wrap with AnimatePresence

* persist screen order

* cleanup

* return all screens when updating indices

* remove y axis anitation

* remove debugging code

* chain filtering and sorting logic

* remove unused var

* fix issue where tabs shift to left on hover

* fix tabElem scroll into view regression

* clear scrollIntoViewTimeout when component unmounts

* remove borken style prop

* completely remove animation related props

* scroll into view only when there's a new tab

* minor comment fix

* resolvePosInt() to resolve index

* move tab width const to magiclayout.ts

* move back scroll into view code as it was before

* clear timout

* refactor setTimeout

* remov debugging codes

* format cmdrunner.go

* move clearTimeout

* remove wheel event listener
This commit is contained in:
Red J Adaya 2023-12-15 13:50:47 +08:00 committed by GitHub
parent 8200a312b9
commit ea9cb37de1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 290 additions and 151 deletions

View File

@ -16,6 +16,7 @@
"dayjs": "^1.11.3",
"dompurify": "^3.0.2",
"electron-squirrel-startup": "^1.0.0",
"framer-motion": "^10.16.16",
"mobx": "6.12",
"mobx-react": "^7.5.0",
"monaco-editor": "^0.44.0",

View File

@ -7,11 +7,11 @@ let MagicLayout = {
CmdInputHeight: 101, // height of full cmd-input div
CmdInputBottom: 12, // .cmd-input
LineHeaderHeight: 46, // .line-header
LinePadding: 24, // .line-header (12px * 2)
WindowHeightOffset: 6, // .window-view, height is calc(100%-0.5rem)
LineHeaderHeight: 46, // .line-header
LinePadding: 24, // .line-header (12px * 2)
WindowHeightOffset: 6, // .window-view, height is calc(100%-0.5rem)
LinesBottomPadding: 10, // .lines, padding
LineMarginTop: 12, // .line, margin
LineMarginTop: 12, // .line, margin
ScreenMaxContentWidthBuffer: 50,
ScreenMaxContentHeightBuffer: 0, // calc below
@ -21,12 +21,15 @@ let MagicLayout = {
// the 3 is for descenders, which get cut off in the terminal without this
TermDescendersHeight: 3,
TermWidthBuffer: 15,
TabWidth: 175,
};
let m = MagicLayout;
// add up all the line overhead + padding. subtract 2 so we don't see the border of neighboring line
m.ScreenMaxContentHeightBuffer = m.LineHeaderHeight + m.LinePadding + m.WindowHeightOffset + m.LinesBottomPadding + m.LineMarginTop - 2;
m.ScreenMaxContentHeightBuffer =
m.LineHeaderHeight + m.LinePadding + m.WindowHeightOffset + m.LinesBottomPadding + m.LineMarginTop - 2;
(window as any).MagicLayout = MagicLayout;

View File

@ -220,96 +220,10 @@
}
}
.screen-tabs {
display: flex;
flex-direction: row;
overflow-x: hidden;
align-items: center;
&:hover,
&:focus {
overflow-x: auto;
}
.screen-tab {
display: flex;
height: 3em;
min-width: 14em;
max-width: 14em;
align-items: center;
cursor: pointer;
position: relative;
.icon.svg-icon {
margin: 0 12px;
svg {
width: 14px;
height: 14px;
}
}
.icon.fa-icon {
font-size: 16px;
margin: 0 12px;
}
.tab-name {
width: 8rem;
}
&.is-active {
border-top: none;
opacity: 1;
}
&.is-archived {
.fa.fa-archive {
margin-right: 4px;
}
}
.tab-index,
.tab-gear {
display: none;
.icon {
border-radius: 50%;
}
}
&:hover {
.tab-gear {
display: block;
}
}
}
&:hover .screen-tab .tab-index {
display: block;
font-size: 0.8em;
}
&:hover .screen-tab:hover .tab-index {
display: none;
}
.new-screen {
flex-shrink: 0;
margin-left: 1em;
margin-right: 1em;
cursor: pointer;
.icon {
height: 2rem;
height: 2rem;
border-radius: 50%;
padding: 0.4em;
vertical-align: middle;
}
}
}
.screen-tabs-container {
display: flex;
position: relative;
overflow: hidden;
&:hover {
z-index: 200;
@ -325,4 +239,95 @@
left: 0px;
display: flex;
}
.screen-tabs {
display: flex;
flex-direction: row;
align-items: center;
overflow-x: hidden;
&:hover,
&:focus {
overflow-x: overlay;
}
.screen-tab {
display: flex;
height: 3em;
min-width: 14em;
max-width: 14em;
align-items: center;
cursor: pointer;
position: relative;
.icon.svg-icon {
margin: 0 12px;
svg {
width: 14px;
height: 14px;
}
}
.icon.fa-icon {
font-size: 16px;
margin: 0 12px;
}
.tab-name {
width: 8rem;
}
&.is-active {
border-top: none;
opacity: 1;
}
&.is-archived {
.fa.fa-archive {
margin-right: 4px;
}
}
.tab-index,
.tab-gear {
display: none;
.icon {
border-radius: 50%;
}
}
&:hover {
.tab-gear {
display: block;
}
}
}
&:hover .screen-tab .tab-index {
display: block;
font-size: 0.8em;
}
&:hover .screen-tab:hover .tab-index {
display: none;
}
}
.new-screen {
flex-shrink: 0;
margin-left: 1em;
margin-right: 1em;
cursor: pointer;
display: flex;
align-items: center;
.icon {
height: 2rem;
height: 2rem;
border-radius: 50%;
padding: 0.4em;
vertical-align: middle;
}
}
}

View File

@ -8,7 +8,6 @@ import { sprintf } from "sprintf-js";
import { boundMethod } from "autobind-decorator";
import { For } from "tsx-control-statements/components";
import cn from "classnames";
import { debounce } from "throttle-debounce";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Session, Screen } from "../../../model/model";
@ -17,30 +16,50 @@ import { ReactComponent as SquareIcon } from "../../assets/icons/tab/square.svg"
import { ReactComponent as ActionsIcon } from "../../assets/icons/tab/actions.svg";
import { ReactComponent as AddIcon } from "../../assets/icons/add.svg";
import * as constants from "../../appconst";
import { Reorder } from "framer-motion";
import { MagicLayout } from "../../magiclayout";
import "../workspace.less";
import "./tabs.less";
dayjs.extend(localizedFormat);
type OV<V> = mobx.IObservableValue<V>;
@mobxReact.observer
class ScreenTabs extends React.Component<{ session: Session }, {}> {
class ScreenTabs extends React.Component<{ session: Session }, { showingScreens: Screen[] }> {
tabsRef: React.RefObject<any> = React.createRef();
tabRefs: { [screenId: string]: React.RefObject<any> } = {};
lastActiveScreenId: string = null;
scrolling: OV<boolean> = mobx.observable.box(false, { name: "screentabs-scrolling" });
stopScrolling_debounced: () => void;
dragEndTimeout = null;
scrollIntoViewTimeout = null;
constructor(props: any) {
super(props);
this.stopScrolling_debounced = debounce(1500, this.stopScrolling.bind(this));
this.state = {
showingScreens: [],
};
}
@mobx.computed
get activeScreenId(): string {
let { session } = this.props;
if (session) {
return session.activeScreenId.get();
}
}
@mobx.computed
get screens(): Screen[] {
if (this.activeScreenId) {
let screens = GlobalModel.getSessionScreens(this.props.session.sessionId);
let showingScreens = screens
.filter((screen) => !screen.archived.get() || this.activeScreenId === screen.screenId)
.sort((a, b) => a.screenIdx.get() - b.screenIdx.get());
return showingScreens;
}
}
@boundMethod
handleNewScreen() {
let { session } = this.props;
GlobalCommandRunner.createNewScreen();
}
@ -60,38 +79,77 @@ class ScreenTabs extends React.Component<{ session: Session }, {}> {
GlobalCommandRunner.switchScreen(screenId);
}
// @boundMethod
// handleWheel(event: WheelEvent) {
// if (!this.tabsRef.current) return;
// // Prevent the default vertical scrolling
// event.preventDefault();
// // Scroll horizontally instead
// this.tabsRef.current.scrollLeft += event.deltaY;
// }
componentDidMount(): void {
this.componentDidUpdate();
// // Add the wheel event listener to the tabsRef
// if (this.tabsRef.current) {
// this.tabsRef.current.addEventListener("wheel", this.handleWheel, { passive: false });
// }
}
componentWillUnmount() {
if (this.dragEndTimeout) {
clearTimeout(this.dragEndTimeout);
clearTimeout(this.scrollIntoViewTimeout);
}
}
componentDidUpdate(): void {
// Scroll the active screen into view
let { session } = this.props;
let activeScreenId = session.activeScreenId.get();
if (activeScreenId != this.lastActiveScreenId && this.tabsRef.current) {
let tabElem = this.tabsRef.current.querySelector(
sprintf('.screen-tab[data-screenid="%s"]', activeScreenId)
);
if (tabElem != null) {
tabElem.scrollIntoView();
if (activeScreenId !== this.lastActiveScreenId) {
if (this.scrollIntoViewTimeout) {
clearTimeout(this.scrollIntoViewTimeout);
}
this.scrollIntoViewTimeout = setTimeout(() => {
if (this.tabsRef.current) {
let tabElem = this.tabsRef.current.querySelector(
sprintf('.screen-tab[data-screenid="%s"]', activeScreenId)
);
if (tabElem) {
tabElem.scrollIntoView();
}
}
this.lastActiveScreenId = activeScreenId;
}, 100);
}
this.lastActiveScreenId = activeScreenId;
}
stopScrolling(): void {
mobx.action(() => {
this.scrolling.set(false);
})();
// Set the showingScreens state if it's not set or if the number of screens has changed.
// Individual screen update are handled automatically by mobx.
if (this.screens && this.state.showingScreens.length !== this.screens.length) {
this.setState({ showingScreens: this.screens });
}
}
@boundMethod
handleScroll() {
if (!this.scrolling.get()) {
mobx.action(() => {
this.scrolling.set(true);
})();
handleDragEnd(screenId) {
if (this.dragEndTimeout) {
clearTimeout(this.dragEndTimeout);
}
this.stopScrolling_debounced();
// Wait for the animation to complete
this.dragEndTimeout = setTimeout(() => {
const tabElement = this.tabRefs[screenId].current;
const finalTabPosition = tabElement.offsetLeft;
// Calculate the new index based on the final position
const newIndex = Math.floor(finalTabPosition / MagicLayout.TabWidth);
GlobalCommandRunner.screenReorder(screenId, `${newIndex + 1}`);
}, 100);
}
@boundMethod
@ -138,17 +196,28 @@ class ScreenTabs extends React.Component<{ session: Session }, {}> {
<i title="shared to web" className="fa-sharp fa-solid fa-share-nodes web-share-icon" />
) : null;
// Create a ref for the tab if it doesn't exist
if (!this.tabRefs[screen.screenId]) {
this.tabRefs[screen.screenId] = React.createRef();
}
return (
<div
key={screen.screenId}
<Reorder.Item
ref={this.tabRefs[screen.screenId]}
value={screen}
id={screen.name.get()}
whileDrag={{
backgroundColor: "rgba(13, 13, 13, 0.85)",
}}
data-screenid={screen.screenId}
className={cn(
"screen-tab",
{ "is-active": activeScreenId == screen.screenId, "is-archived": screen.archived.get() },
"color-" + screen.getTabColor()
)}
onClick={() => this.handleSwitchScreen(screen.screenId)}
onPointerDown={() => this.handleSwitchScreen(screen.screenId)}
onContextMenu={(event) => this.openScreenSettings(event, screen)}
onDragEnd={() => this.handleDragEnd(screen.screenId)}
>
{this.renderTabIcon(screen)}
<div className="tab-name truncate">
@ -158,49 +227,39 @@ class ScreenTabs extends React.Component<{ session: Session }, {}> {
</div>
{tabIndex}
{settings}
</div>
</Reorder.Item>
);
}
render() {
let { showingScreens } = this.state;
let { session } = this.props;
if (session == null) {
return null;
}
let screen: Screen | null = null;
let index = 0;
let showingScreens = [];
let activeScreenId = session.activeScreenId.get();
let screens = GlobalModel.getSessionScreens(session.sessionId);
for (let screen of screens) {
if (!screen.archived.get() || activeScreenId == screen.screenId) {
showingScreens.push(screen);
}
}
showingScreens.sort((a, b) => {
let aidx = a.screenIdx.get();
let bidx = b.screenIdx.get();
if (aidx < bidx) {
return -1;
}
if (aidx > bidx) {
return 1;
}
return 0;
});
return (
<div className="screen-tabs-container">
<div
className={cn("screen-tabs", { scrolling: this.scrolling.get() })}
<Reorder.Group
className="screen-tabs"
ref={this.tabsRef}
onScroll={this.handleScroll}
as="ul"
axis="x"
onReorder={(tabs: Screen[]) => {
this.setState({ showingScreens: tabs });
}}
values={showingScreens}
>
<For each="screen" index="index" of={showingScreens}>
{this.renderTab(screen, activeScreenId, index)}
<React.Fragment key={screen.screenId}>
{this.renderTab(screen, activeScreenId, index)}
</React.Fragment>
</For>
<div key="new-screen" className="new-screen" onClick={this.handleNewScreen}>
<AddIcon className="icon hoverEffect" />
</div>
</Reorder.Group>
<div key="new-screen" className="new-screen" onClick={this.handleNewScreen}>
<AddIcon className="icon hoverEffect" />
</div>
</div>
);

View File

@ -4196,6 +4196,15 @@ class CommandRunner {
GlobalModel.submitCommand("screen", "set", null, kwargs, false);
}
screenReorder(screenId: string, index: string) {
let kwargs: Record<string, string> = {
nohist: "1",
screenId: screenId,
index: index,
};
GlobalModel.submitCommand("screen", "reorder", null, kwargs, false);
}
setTermUsedRows(termContext: RendererContext, height: number) {
let kwargs: Record<string, string> = {};
kwargs["screen"] = termContext.screenId;

View File

@ -90,13 +90,14 @@ var SetVarNameMap map[string]string = map[string]string{
"anchor": "screen.anchor",
"focus": "screen.focus",
"line": "screen.line",
"index": "screen.index",
}
var SetVarScopes = []SetVarScope{
{ScopeName: "global", VarNames: []string{}},
{ScopeName: "client", VarNames: []string{"telemetry"}},
{ScopeName: "session", VarNames: []string{"name", "pos"}},
{ScopeName: "screen", VarNames: []string{"name", "tabcolor", "tabicon", "pos", "pterm", "anchor", "focus", "line"}},
{ScopeName: "screen", VarNames: []string{"name", "tabcolor", "tabicon", "pos", "pterm", "anchor", "focus", "line", "index"}},
{ScopeName: "line", VarNames: []string{}},
// connection = remote, remote = remoteinstance
{ScopeName: "connection", VarNames: []string{"alias", "connectmode", "key", "password", "autoinstall", "color"}},
@ -167,6 +168,7 @@ func init() {
registerCmdFn("screen:showall", ScreenShowAllCommand)
registerCmdFn("screen:reset", ScreenResetCommand)
registerCmdFn("screen:webshare", ScreenWebShareCommand)
registerCmdFn("screen:reorder", ScreenReorderCommand)
registerCmdAlias("remote", RemoteCommand)
registerCmdFn("remote:show", RemoteShowCommand)
@ -723,6 +725,45 @@ func ScreenOpenCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (s
return update, nil
}
func ScreenReorderCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
// Resolve the UI IDs for the session and screen
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
if err != nil {
return nil, err
}
// Extract the screen ID and the new index from the packet
screenId := ids.ScreenId
newScreenIdxStr := pk.Kwargs["index"]
newScreenIdx, err := resolvePosInt(newScreenIdxStr, 1)
if err != nil {
return nil, fmt.Errorf("invalid new screen index: %v", err)
}
// Call SetScreenIdx to update the screen's index in the database
err = sstore.SetScreenIdx(ctx, ids.SessionId, screenId, newScreenIdx)
if err != nil {
return nil, fmt.Errorf("error updating screen index: %v", err)
}
// Retrieve all session screens
screens, err := sstore.GetSessionScreens(ctx, ids.SessionId)
if err != nil {
return nil, fmt.Errorf("error retrieving updated screen: %v", err)
}
// Prepare the update packet to send back to the client
update := &sstore.ModelUpdate{
Screens: screens,
Info: &sstore.InfoMsgType{
InfoMsg: "screen indices updated successfully",
TimeoutMs: 2000,
},
}
return update, nil
}
func ScreenSetCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sstore.UpdatePacket, error) {
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen)
if err != nil {

View File

@ -1699,6 +1699,18 @@
minimatch "^3.0.4"
plist "^3.0.4"
"@emotion/is-prop-valid@^0.8.2":
version "0.8.8"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a"
integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==
dependencies:
"@emotion/memoize" "0.7.4"
"@emotion/memoize@0.7.4":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb"
integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==
"@gar/promisify@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@ -4225,6 +4237,15 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
framer-motion@^10.16.16:
version "10.16.16"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-10.16.16.tgz#a10a03e1190a717109163cfff212a84c8ad11b0c"
integrity sha512-je6j91rd7NmUX7L1XHouwJ4v3R+SO4umso2LUcgOct3rHZ0PajZ80ETYZTajzEXEl9DlKyzjyt4AvGQ+lrebOw==
dependencies:
tslib "^2.4.0"
optionalDependencies:
"@emotion/is-prop-valid" "^0.8.2"
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
@ -7891,7 +7912,7 @@ trough@^2.0.0:
resolved "https://registry.yarnpkg.com/trough/-/trough-2.1.0.tgz#0f7b511a4fde65a46f18477ab38849b22c554876"
integrity sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0:
tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.4.0:
version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==