Skip to content

Commit 86f8ef5

Browse files
feat: resizable columns in notebook (#4698)
## 📝 Summary <!-- Provide a concise summary of what this pull request is addressing. If this PR fixes any issues, list them here by number (e.g., Fixes #123). --> Adds a drag handle at the right side for column based notebooks. ![image](https://github.com/user-attachments/assets/b4d4b17d-97d5-4f59-ae7e-16bda16512bf) Todo: - [x] reordering columns and adjusting the width doesn't save properly - [ ] dragging a column beyond viewport may not be very smooth - [x] move resizable component to common folder (maybe) - [x] add tests - [X] file rename ## 🔍 Description of Changes <!-- Detail the specific changes made in this pull request. Explain the problem addressed and how it was resolved. If applicable, provide before and after comparisons, screenshots, or any relevant details to help reviewers understand the changes easily. --> ## 📋 Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] I have added tests for the changes made. - [x] I have run the code and verified that it works as expected. ## 📜 Reviewers <!-- Tag potential reviewers from the community or maintainers who might be interested in reviewing this pull request. Your PR will be reviewed more quickly if you can figure out the right person to tag with @ --> --------- Co-authored-by: Myles Scolnick <myles@marimo.io>
1 parent 763c6a3 commit 86f8ef5

File tree

15 files changed

+516
-36
lines changed

15 files changed

+516
-36
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
import { describe, it, expect, afterEach } from "vitest";
3+
import { reorderColumnSizes, storageFn } from "../storage";
4+
5+
describe("setColumnWidth", () => {
6+
const { clearStorage, getColumnWidth, setColumnWidth } = storageFn;
7+
8+
afterEach(() => {
9+
clearStorage();
10+
});
11+
12+
it("should set width for an existing index", () => {
13+
// Setup initial state
14+
setColumnWidth(0, 100);
15+
setColumnWidth(1, 200);
16+
17+
// Test
18+
setColumnWidth(0, 150);
19+
20+
// Verify
21+
expect(getColumnWidth(0)).toBe(150);
22+
expect(getColumnWidth(1)).toBe(200);
23+
});
24+
25+
it("should set width for a new index", () => {
26+
// Setup initial state
27+
setColumnWidth(0, 100);
28+
29+
// Test
30+
setColumnWidth(1, 200);
31+
32+
// Verify
33+
expect(getColumnWidth(0)).toBe(100);
34+
expect(getColumnWidth(1)).toBe(200);
35+
});
36+
37+
it("should pad with contentWidth when setting width for out of bounds index", () => {
38+
// Setup initial state
39+
setColumnWidth(0, 100);
40+
41+
// Test
42+
setColumnWidth(3, 300);
43+
44+
// Verify
45+
expect(getColumnWidth(0)).toBe(100);
46+
expect(getColumnWidth(1)).toBe("contentWidth");
47+
expect(getColumnWidth(2)).toBe("contentWidth");
48+
expect(getColumnWidth(3)).toBe(300);
49+
});
50+
51+
it("should handle empty initial state", () => {
52+
// Test
53+
setColumnWidth(2, 200);
54+
55+
// Verify
56+
expect(getColumnWidth(0)).toBe("contentWidth");
57+
expect(getColumnWidth(1)).toBe("contentWidth");
58+
expect(getColumnWidth(2)).toBe(200);
59+
});
60+
61+
it("should update multiple columns", () => {
62+
// Setup initial state
63+
setColumnWidth(0, 100);
64+
setColumnWidth(1, 200);
65+
setColumnWidth(2, 300);
66+
67+
// Test
68+
setColumnWidth(0, 150);
69+
setColumnWidth(2, 350);
70+
71+
// Verify
72+
expect(getColumnWidth(0)).toBe(150);
73+
expect(getColumnWidth(1)).toBe(200);
74+
expect(getColumnWidth(2)).toBe(350);
75+
});
76+
77+
it("should set contentWidth directly", () => {
78+
// Setup initial state
79+
setColumnWidth(0, 100);
80+
setColumnWidth(1, 200);
81+
82+
// Test
83+
setColumnWidth(0, "contentWidth");
84+
85+
// Verify
86+
expect(getColumnWidth(0)).toBe("contentWidth");
87+
expect(getColumnWidth(1)).toBe(200);
88+
});
89+
90+
it("should maintain correct widths after reordering", () => {
91+
// Setup initial state with 3 columns
92+
setColumnWidth(0, 100);
93+
setColumnWidth(1, 200);
94+
setColumnWidth(2, 300);
95+
96+
// Reorder column 0 to position 2
97+
reorderColumnSizes(0, 2);
98+
99+
// Verify the widths are in the correct order after reordering
100+
expect(getColumnWidth(0)).toBe(200); // Original column 1
101+
expect(getColumnWidth(1)).toBe(300); // Original column 2
102+
expect(getColumnWidth(2)).toBe(100); // Original column 0
103+
});
104+
});

frontend/src/components/editor/columns/cell-column.tsx

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
2-
import { cn } from "@/utils/cn";
32
import { memo, useRef } from "react";
43
import { SortableColumn } from "./sortable-column";
54
import type { CellColumnId } from "@/utils/id-tree";
65
import type { AppConfig } from "@/core/config/config-schema";
6+
import { storageFn } from "./storage";
7+
import { useResizeHandle } from "@/hooks/useResizeHandle";
78

89
interface Props {
910
className?: string;
1011
columnId: CellColumnId;
12+
index: number;
1113
children: React.ReactNode;
1214
width: AppConfig["width"];
1315
footer?: React.ReactNode;
@@ -16,21 +18,24 @@ interface Props {
1618
canMoveRight: boolean;
1719
}
1820

21+
const { getColumnWidth, setColumnWidth } = storageFn;
22+
1923
export const Column = memo((props: Props) => {
2024
const columnRef = useRef<HTMLDivElement>(null);
2125

22-
const column = (
23-
<div
24-
className={cn(
25-
"flex flex-col gap-5",
26-
// box-content is needed so the column is width=contentWidth, but not affected by padding
27-
props.width === "columns" &&
28-
"w-contentWidth box-content min-h-[100px] px-11 py-6",
29-
)}
30-
>
31-
{props.children}
32-
</div>
33-
);
26+
const column: React.ReactNode =
27+
props.width === "columns" ? (
28+
<ResizableComponent
29+
startingWidth={getColumnWidth(props.index)}
30+
onResize={(width: number) => {
31+
setColumnWidth(props.index, width);
32+
}}
33+
>
34+
{props.children}
35+
</ResizableComponent>
36+
) : (
37+
<div className="flex flex-col gap-5">{props.children}</div>
38+
);
3439

3540
if (props.width === "columns") {
3641
return (
@@ -58,3 +63,38 @@ export const Column = memo((props: Props) => {
5863
});
5964

6065
Column.displayName = "Column";
66+
67+
interface ResizableComponentProps {
68+
startingWidth: number | "contentWidth";
69+
onResize?: (width: number) => void;
70+
children: React.ReactNode;
71+
}
72+
73+
const ResizableComponent = ({
74+
startingWidth,
75+
onResize,
76+
children,
77+
}: ResizableComponentProps) => {
78+
const { resizableDivRef, handleRef, style } = useResizeHandle({
79+
startingWidth,
80+
onResize,
81+
});
82+
83+
return (
84+
<div className="flex flex-row gap-2">
85+
<div
86+
ref={resizableDivRef}
87+
className="flex flex-col gap-5 box-content min-h-[100px] px-11 py-6 min-w-[500px] z-1"
88+
style={style}
89+
>
90+
{children}
91+
</div>
92+
<div
93+
ref={handleRef}
94+
className="w-1 cursor-col-resize transition-colors duration-200 z-10
95+
group-hover/column:bg-[var(--slate-3)] dark:group-hover/column:bg-[var(--slate-5)]
96+
group-hover/column:hover:bg-primary/60 dark:group-hover/column:hover:bg-primary/60"
97+
/>
98+
</div>
99+
);
100+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
import { arrayMove } from "@/utils/arrays";
3+
import { NotebookScopedLocalStorage } from "@/utils/localStorage";
4+
import { z } from "zod";
5+
6+
const BASE_KEY = "marimo:notebook-col-sizes";
7+
8+
interface ColumnSizes {
9+
widths: Array<number | "contentWidth">;
10+
}
11+
12+
function initialState(): ColumnSizes {
13+
return { widths: [] };
14+
}
15+
16+
const storage = new NotebookScopedLocalStorage<ColumnSizes>(
17+
BASE_KEY,
18+
z.object({
19+
widths: z.array(z.union([z.number(), z.literal("contentWidth")])),
20+
}),
21+
initialState,
22+
);
23+
24+
export const storageFn = {
25+
// Default to "contentWidth" if the column width is not set.
26+
getColumnWidth: (index: number) => {
27+
const widths = storage.get().widths;
28+
return widths[index] ?? "contentWidth";
29+
},
30+
setColumnWidth: (index: number, width: number | "contentWidth") => {
31+
const widths = storage.get().widths;
32+
if (widths[index]) {
33+
widths[index] = width;
34+
} else {
35+
// If the index is out of bounds, add "contentWidth" until we reach the index
36+
while (widths.length <= index) {
37+
widths.push("contentWidth");
38+
}
39+
widths[index] = width;
40+
}
41+
storage.set({ widths });
42+
},
43+
clearStorage: () => {
44+
storage.remove();
45+
},
46+
};
47+
48+
// When a column is reordered, we need to update the storage to reflect the new order.
49+
export function reorderColumnSizes(fromIdx: number, toIdx: number) {
50+
const widths = storage.get().widths;
51+
const newWidths = arrayMove(widths, fromIdx, toIdx);
52+
storage.set({ widths: newWidths });
53+
}

frontend/src/components/editor/renderers/CellArray.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ const CellColumn: React.FC<{
197197
return (
198198
<Column
199199
columnId={column.id}
200+
index={index}
200201
canMoveLeft={index > 0}
201202
canMoveRight={index < columnsLength - 1}
202203
width={appConfig.width}

frontend/src/core/codemirror/markdown/__tests__/commands.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
insertUL,
2222
} from "../commands";
2323
import { sendCreateFileOrFolder } from "@/core/network/requests";
24-
import { filenameAtom } from "@/core/saving/filename";
24+
import { filenameAtom } from "@/core/saving/filenameAtom";
2525
import { store } from "@/core/state/jotai";
2626

2727
function createEditor(content: string) {

frontend/src/core/codemirror/markdown/commands.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22
import { toast } from "@/components/ui/use-toast";
33
import { sendCreateFileOrFolder } from "@/core/network/requests";
4-
import { filenameAtom } from "@/core/saving/filename";
4+
import { filenameAtom } from "@/core/saving/filenameAtom";
55
import { store } from "@/core/state/jotai";
66
import { Paths, type FilePath } from "@/utils/paths";
77
import {

frontend/src/core/saving/filename.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* Copyright 2024 Marimo. All rights reserved. */
22

3-
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
4-
import { getFilenameFromDOM } from "../dom/htmlUtils";
3+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
54
import { sendRename } from "../network/requests";
65
import { Paths } from "@/utils/paths";
76
import { updateQueryParams } from "@/utils/urls";
@@ -11,8 +10,7 @@ import { WebSocketState } from "../websocket/types";
1110
import { useImperativeModal } from "@/components/modal/ImperativeModal";
1211
import { connectionAtom } from "../network/connection";
1312
import { getAppConfig } from "../config/config";
14-
15-
export const filenameAtom = atom<string | null>(getFilenameFromDOM());
13+
import { filenameAtom } from "./filenameAtom";
1614

1715
export function useFilename() {
1816
return useAtomValue(filenameAtom);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/* Copyright 2024 Marimo. All rights reserved. */
2+
3+
import { atom } from "jotai";
4+
import { getFilenameFromDOM } from "../dom/htmlUtils";
5+
6+
// The atom is separated from the filename logic to avoid circular dependencies.
7+
/**
8+
* Atom for storing the current notebook filename.
9+
* This is used to scope local storage to the current notebook.
10+
*/
11+
export const filenameAtom = atom<string | null>(getFilenameFromDOM());

frontend/src/core/saving/save-component.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import { useAutoSave } from "./useAutoSave";
2121
import { getSerializedLayout, layoutStateAtom } from "../layout/layout";
2222
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
2323
import { formatAll } from "../codemirror/format";
24-
import { filenameAtom, useFilename, useUpdateFilename } from "./filename";
24+
import { useFilename, useUpdateFilename } from "./filename";
25+
import { filenameAtom } from "./filenameAtom";
2526
import { connectionAtom } from "../network/connection";
2627
import { autoSaveConfigAtom } from "../config/config";
2728
import { lastSavedNotebookAtom, needsSaveAtom } from "./state";

frontend/src/css/app/Cell.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@
220220
justify-content: flex-end;
221221
height: 100%;
222222
position: absolute;
223-
right: -90px;
224-
width: 80px;
223+
left: calc(100% + 12px);
224+
width: fit-content;
225225
gap: 4px;
226226
}
227227

0 commit comments

Comments
 (0)