@marimo-team/islands 0.23.7-dev16 → 0.23.7-dev17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ConnectedDataExplorerComponent-DnRhpPMJ.js → ConnectedDataExplorerComponent-2lBNiUv6.js} +13 -13
- package/dist/{ErrorBoundary-Da4UeYxT.js → ErrorBoundary-D3wrPNma.js} +1 -1
- package/dist/{any-language-editor-DDubl8YH.js → any-language-editor-VWs_7v27.js} +5 -5
- package/dist/{button-CA5pI2YF.js → button-Dj4BTre0.js} +5 -0
- package/dist/{capabilities-6laDasij.js → capabilities-C9rrYCzf.js} +1 -1
- package/dist/{chat-ui-BmWZZ3mE.js → chat-ui-Cf4tuQ8s.js} +16 -16
- package/dist/{check-CFM2mVDr.js → check-BcUIXnUT.js} +1 -1
- package/dist/{code-visibility-BTdq0PKn.js → code-visibility-god-8ESC.js} +21 -21
- package/dist/{copy-TGGAUEWp.js → copy-DLf4aN7I.js} +2 -2
- package/dist/{dist-ESg7xyoD.js → dist-D3ZI9nhS.js} +2 -2
- package/dist/{error-banner-DnBPzEWg.js → error-banner-CVkfBUT3.js} +2 -2
- package/dist/{esm-Dd1z1auZ.js → esm-CWp0KQeK.js} +1 -1
- package/dist/{extends-CzJgxo2J.js → extends-vAi97cpa.js} +4 -4
- package/dist/{formats-CgaK7Gmx.js → formats-CpgZM9BM.js} +3 -3
- package/dist/{glide-data-editor-B-3A3G02.js → glide-data-editor-BK9s_dqy.js} +9 -9
- package/dist/{html-to-image-BwZL1Pkk.js → html-to-image-q0T1ijn_.js} +2213 -2143
- package/dist/{input-BAOe64zx.js → input-Cc1Vvw9A.js} +6 -6
- package/dist/{label-BCWi-Oqu.js → label-BLqV33b1.js} +2 -2
- package/dist/{loader-BvW0-YWZ.js → loader-Dr8Qem8p.js} +1 -1
- package/dist/main.js +33 -33
- package/dist/{mermaid-cXSZ1pfD.js → mermaid-DO-Daq7u.js} +5 -5
- package/dist/{process-output-lpVrk7d5.js → process-output-DTKS9bKk.js} +3 -3
- package/dist/{reveal-component-CMK1wxUM.js → reveal-component-vPlC5Eve.js} +9 -9
- package/dist/{spec-DSIuqd3f.js → spec-hVaaZsY5.js} +4 -4
- package/dist/{strings-B_FOH6eV.js → strings-BiIhGaI8.js} +4 -4
- package/dist/{swiper-component-BHs0PWwp.js → swiper-component-DlD2GU2g.js} +2 -2
- package/dist/{toDate-CHtl9vts.js → toDate-CJWlVNGD.js} +3 -3
- package/dist/{tooltip-B0mtKTXm.js → tooltip-DRaMBu06.js} +3 -3
- package/dist/{types-DBtDeUKD.js → types-Dzuoc3LN.js} +1 -1
- package/dist/{useAsyncData-B6hCGywC.js → useAsyncData-C56Khv_R.js} +1 -1
- package/dist/{useDateFormatter-B3mCQMP3.js → useDateFormatter-B_9k85Ex.js} +2 -2
- package/dist/{useDeepCompareMemoize-CmwDuYUH.js → useDeepCompareMemoize-Dt98v2ua.js} +1 -1
- package/dist/{useIframeCapabilities-DbdLoEDm.js → useIframeCapabilities-BkYHTrss.js} +1 -1
- package/dist/{useLifecycle-CjMjllqy.js → useLifecycle-BF6-z62y.js} +3 -3
- package/dist/{useTheme-CByZUW0p.js → useTheme-DykuNHR2.js} +2 -2
- package/dist/{vega-component-C2BYPkfd.js → vega-component-BtvQ-Kc4.js} +10 -10
- package/dist/{zod-BxdsqRPd.js → zod-BWkcDORu.js} +1 -1
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +3 -1
- package/src/components/editor/controls/Controls.tsx +3 -1
- package/src/components/editor/navigation/__tests__/clipboard.test.ts +107 -0
- package/src/components/editor/navigation/__tests__/navigation.test.ts +70 -0
- package/src/components/editor/navigation/clipboard.ts +101 -23
- package/src/components/editor/navigation/navigation.ts +15 -1
- package/src/components/editor/notebook-cell.tsx +3 -0
- package/src/core/cells/__tests__/cells.test.ts +187 -0
- package/src/core/cells/__tests__/pending-cut-service.test.tsx +145 -0
- package/src/core/cells/cells.ts +102 -17
- package/src/core/cells/document-changes.ts +6 -1
- package/src/core/cells/pending-cut-service.ts +64 -0
- package/src/core/cells/utils.ts +11 -0
- package/src/core/codemirror/cells/extensions.ts +10 -0
- package/src/core/hotkeys/hotkeys.ts +5 -0
- package/src/css/app/Cell.css +13 -0
- package/src/utils/__tests__/id-tree.test.ts +71 -0
- package/src/utils/id-tree.tsx +89 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { createStore, Provider } from "jotai";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import type { CellId } from "@/core/cells/ids";
|
|
7
|
+
import {
|
|
8
|
+
pendingCutStateAtom,
|
|
9
|
+
useHasPendingCut,
|
|
10
|
+
useIsPendingCut,
|
|
11
|
+
usePendingCutActions,
|
|
12
|
+
usePendingCutState,
|
|
13
|
+
} from "../pending-cut-service";
|
|
14
|
+
|
|
15
|
+
function createTestWrapper() {
|
|
16
|
+
const store = createStore();
|
|
17
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
18
|
+
<Provider store={store}>{children}</Provider>
|
|
19
|
+
);
|
|
20
|
+
return { wrapper, store };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mockClipboardData = {
|
|
24
|
+
cells: [{ code: "x = 1", name: "cell1" }],
|
|
25
|
+
version: "1.0" as const,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("pending-cut-service", () => {
|
|
29
|
+
it("markForCut sets cellIds and clipboardData", () => {
|
|
30
|
+
const { wrapper, store } = createTestWrapper();
|
|
31
|
+
const cellIds: CellId[] = ["cell-1" as CellId, "cell-2" as CellId];
|
|
32
|
+
|
|
33
|
+
const { result } = renderHook(
|
|
34
|
+
() => ({
|
|
35
|
+
actions: usePendingCutActions(),
|
|
36
|
+
state: usePendingCutState(),
|
|
37
|
+
}),
|
|
38
|
+
{ wrapper },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
act(() => {
|
|
42
|
+
result.current.actions.markForCut({
|
|
43
|
+
cellIds,
|
|
44
|
+
clipboardData: mockClipboardData,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const state = store.get(pendingCutStateAtom);
|
|
49
|
+
expect(state.cellIds).toEqual(new Set(cellIds));
|
|
50
|
+
expect(state.clipboardData).toEqual(mockClipboardData);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("clear resets to initial state", () => {
|
|
54
|
+
const { wrapper, store } = createTestWrapper();
|
|
55
|
+
const cellIds: CellId[] = ["cell-1" as CellId];
|
|
56
|
+
|
|
57
|
+
const { result } = renderHook(
|
|
58
|
+
() => ({
|
|
59
|
+
actions: usePendingCutActions(),
|
|
60
|
+
state: usePendingCutState(),
|
|
61
|
+
}),
|
|
62
|
+
{ wrapper },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
act(() => {
|
|
66
|
+
result.current.actions.markForCut({
|
|
67
|
+
cellIds,
|
|
68
|
+
clipboardData: mockClipboardData,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
expect(store.get(pendingCutStateAtom).cellIds.size).toBe(1);
|
|
72
|
+
|
|
73
|
+
act(() => {
|
|
74
|
+
result.current.actions.clear();
|
|
75
|
+
});
|
|
76
|
+
const state = store.get(pendingCutStateAtom);
|
|
77
|
+
expect(state.cellIds.size).toBe(0);
|
|
78
|
+
expect(state.clipboardData).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("useIsPendingCut returns true when cellId is marked for cut", () => {
|
|
82
|
+
const { wrapper } = createTestWrapper();
|
|
83
|
+
const cellId = "cell-1" as CellId;
|
|
84
|
+
|
|
85
|
+
const { result: actionsResult } = renderHook(() => usePendingCutActions(), {
|
|
86
|
+
wrapper,
|
|
87
|
+
});
|
|
88
|
+
const { result: isPendingResult } = renderHook(
|
|
89
|
+
() => useIsPendingCut(cellId),
|
|
90
|
+
{ wrapper },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(isPendingResult.current).toBe(false);
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
actionsResult.current.markForCut({
|
|
97
|
+
cellIds: [cellId],
|
|
98
|
+
clipboardData: mockClipboardData,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(isPendingResult.current).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("useIsPendingCut returns false when cellId is not marked for cut", () => {
|
|
106
|
+
const { wrapper } = createTestWrapper();
|
|
107
|
+
const { result } = renderHook(
|
|
108
|
+
() => useIsPendingCut("other-cell" as CellId),
|
|
109
|
+
{ wrapper },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const { result: actionsResult } = renderHook(() => usePendingCutActions(), {
|
|
113
|
+
wrapper,
|
|
114
|
+
});
|
|
115
|
+
act(() => {
|
|
116
|
+
actionsResult.current.markForCut({
|
|
117
|
+
cellIds: ["cell-1" as CellId],
|
|
118
|
+
clipboardData: mockClipboardData,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.current).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("useHasPendingCut returns true when any cells are marked for cut", () => {
|
|
126
|
+
const { wrapper } = createTestWrapper();
|
|
127
|
+
const { result: hasPendingResult } = renderHook(() => useHasPendingCut(), {
|
|
128
|
+
wrapper,
|
|
129
|
+
});
|
|
130
|
+
const { result: actionsResult } = renderHook(() => usePendingCutActions(), {
|
|
131
|
+
wrapper,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(hasPendingResult.current).toBe(false);
|
|
135
|
+
|
|
136
|
+
act(() => {
|
|
137
|
+
actionsResult.current.markForCut({
|
|
138
|
+
cellIds: ["cell-1" as CellId],
|
|
139
|
+
clipboardData: mockClipboardData,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(hasPendingResult.current).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/core/cells/cells.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
splitEditor,
|
|
24
24
|
updateEditorCodeFromPython,
|
|
25
25
|
} from "../codemirror/language/utils";
|
|
26
|
+
import type { SerializedEditorState } from "../codemirror/types";
|
|
26
27
|
import { findCollapseRange, mergeOutlines } from "../dom/outline";
|
|
27
28
|
import type { CellMessage } from "../kernel/messages";
|
|
28
29
|
import { isErrorMime } from "../mime";
|
|
@@ -50,11 +51,36 @@ import {
|
|
|
50
51
|
canUndoDeletes,
|
|
51
52
|
disabledCellIds,
|
|
52
53
|
enabledCellIds,
|
|
54
|
+
getUndoLabel,
|
|
53
55
|
notebookIsRunning,
|
|
54
56
|
notebookNeedsRun,
|
|
55
57
|
notebookQueueOrRunningCount,
|
|
56
58
|
} from "./utils";
|
|
57
59
|
|
|
60
|
+
/**
|
|
61
|
+
* History entry for undoing a cell deletion.
|
|
62
|
+
*/
|
|
63
|
+
export interface UndoDeleteEntry {
|
|
64
|
+
type: "delete";
|
|
65
|
+
name: string;
|
|
66
|
+
serializedEditorState: SerializedEditorState;
|
|
67
|
+
column: CellColumnId;
|
|
68
|
+
index: CellIndex;
|
|
69
|
+
isSetupCell: boolean;
|
|
70
|
+
config: CellConfig;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* History entry for undoing a cut-paste (move).
|
|
75
|
+
*/
|
|
76
|
+
export interface UndoMoveEntry {
|
|
77
|
+
type: "move";
|
|
78
|
+
cellIds: CellId[];
|
|
79
|
+
placements: Array<{ columnId: CellColumnId; index: CellIndex }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type HistoryEntry = UndoDeleteEntry | UndoMoveEntry;
|
|
83
|
+
|
|
58
84
|
/**
|
|
59
85
|
* The state of the notebook.
|
|
60
86
|
*/
|
|
@@ -76,19 +102,9 @@ export interface NotebookState {
|
|
|
76
102
|
*/
|
|
77
103
|
cellHandles: Record<CellId, React.RefObject<CellHandle | null>>;
|
|
78
104
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* (CodeMirror types the serialized config as any.)
|
|
105
|
+
* Undo stack: deleted cells and cut-paste moves, in chronological order.
|
|
82
106
|
*/
|
|
83
|
-
history:
|
|
84
|
-
name: string;
|
|
85
|
-
// oxlint-disable-next-line typescript/no-explicit-any
|
|
86
|
-
serializedEditorState: any;
|
|
87
|
-
column: CellColumnId;
|
|
88
|
-
index: CellIndex;
|
|
89
|
-
isSetupCell: boolean;
|
|
90
|
-
config: CellConfig;
|
|
91
|
-
}[];
|
|
107
|
+
history: HistoryEntry[];
|
|
92
108
|
/**
|
|
93
109
|
* Key of cell to scroll to; typically set by actions that re-order the cell
|
|
94
110
|
* array. Call the SCROLL_TO_TARGET action to scroll to the specified cell
|
|
@@ -158,6 +174,10 @@ export interface CreateNewCellAction {
|
|
|
158
174
|
before: boolean;
|
|
159
175
|
/** Initial code content for the new cell */
|
|
160
176
|
code?: string;
|
|
177
|
+
/** Optional name for the new cell */
|
|
178
|
+
name?: string;
|
|
179
|
+
/** Optional cell configuration */
|
|
180
|
+
config?: CellConfig;
|
|
161
181
|
/** The last executed code for the new cell */
|
|
162
182
|
lastCodeRun?: string;
|
|
163
183
|
/** Timestamp of the last execution */
|
|
@@ -187,11 +207,13 @@ const {
|
|
|
187
207
|
cellId,
|
|
188
208
|
before,
|
|
189
209
|
code,
|
|
210
|
+
name,
|
|
211
|
+
config,
|
|
190
212
|
lastCodeRun = null,
|
|
191
213
|
lastExecutionTime = null,
|
|
192
214
|
autoFocus = true,
|
|
193
215
|
skipIfCodeExists = false,
|
|
194
|
-
hideCode =
|
|
216
|
+
hideCode = undefined,
|
|
195
217
|
} = action;
|
|
196
218
|
|
|
197
219
|
let columnId: CellColumnId;
|
|
@@ -234,8 +256,12 @@ const {
|
|
|
234
256
|
[newCellId]: createCell({
|
|
235
257
|
id: newCellId,
|
|
236
258
|
code,
|
|
259
|
+
name,
|
|
237
260
|
lastCodeRun,
|
|
238
|
-
config: createCellConfig({
|
|
261
|
+
config: createCellConfig({
|
|
262
|
+
...config,
|
|
263
|
+
...(hideCode != null && { hide_code: hideCode }),
|
|
264
|
+
}),
|
|
239
265
|
lastExecutionTime,
|
|
240
266
|
edited: Boolean(code) && code !== lastCodeRun,
|
|
241
267
|
}),
|
|
@@ -417,6 +443,40 @@ const {
|
|
|
417
443
|
scrollKey: null,
|
|
418
444
|
};
|
|
419
445
|
},
|
|
446
|
+
moveCellsRelativeTo: (
|
|
447
|
+
state,
|
|
448
|
+
action: {
|
|
449
|
+
cellIds: CellId[];
|
|
450
|
+
targetCellId: CellId;
|
|
451
|
+
position: "before" | "after";
|
|
452
|
+
previousPlacements?: Array<{ columnId: CellColumnId; index: CellIndex }>;
|
|
453
|
+
},
|
|
454
|
+
) => {
|
|
455
|
+
const { cellIds, targetCellId, position, previousPlacements } = action;
|
|
456
|
+
if (cellIds.length === 0) {
|
|
457
|
+
return state;
|
|
458
|
+
}
|
|
459
|
+
const newCellIds = state.cellIds.moveCellsRelativeTo(
|
|
460
|
+
cellIds,
|
|
461
|
+
targetCellId,
|
|
462
|
+
position,
|
|
463
|
+
);
|
|
464
|
+
// Only record undo when caller provided full before-state
|
|
465
|
+
const canUndoMove =
|
|
466
|
+
previousPlacements && previousPlacements.length === cellIds.length;
|
|
467
|
+
const history = canUndoMove
|
|
468
|
+
? [
|
|
469
|
+
...state.history,
|
|
470
|
+
{ type: "move" as const, cellIds, placements: previousPlacements },
|
|
471
|
+
]
|
|
472
|
+
: state.history;
|
|
473
|
+
return {
|
|
474
|
+
...state,
|
|
475
|
+
cellIds: newCellIds,
|
|
476
|
+
history,
|
|
477
|
+
scrollKey: null,
|
|
478
|
+
};
|
|
479
|
+
},
|
|
420
480
|
dropCellOverColumn: (
|
|
421
481
|
state,
|
|
422
482
|
action: { cellId: CellId; columnId: CellColumnId },
|
|
@@ -659,6 +719,7 @@ const {
|
|
|
659
719
|
history: [
|
|
660
720
|
...state.history,
|
|
661
721
|
{
|
|
722
|
+
type: "delete",
|
|
662
723
|
name: prevData.name,
|
|
663
724
|
serializedEditorState: serializedEditorState,
|
|
664
725
|
column: column.id,
|
|
@@ -675,7 +736,29 @@ const {
|
|
|
675
736
|
return state;
|
|
676
737
|
}
|
|
677
738
|
|
|
678
|
-
const
|
|
739
|
+
const last = state.history[state.history.length - 1];
|
|
740
|
+
|
|
741
|
+
if (last.type === "move") {
|
|
742
|
+
const { cellIds, placements } = last;
|
|
743
|
+
if (
|
|
744
|
+
cellIds.length === 0 ||
|
|
745
|
+
placements.length !== cellIds.length ||
|
|
746
|
+
cellIds.some((id) => !state.cellData[id])
|
|
747
|
+
) {
|
|
748
|
+
return { ...state, history: state.history.slice(0, -1) };
|
|
749
|
+
}
|
|
750
|
+
const toRestore = cellIds.map((id, i) => ({
|
|
751
|
+
id,
|
|
752
|
+
columnId: placements[i].columnId,
|
|
753
|
+
index: placements[i].index,
|
|
754
|
+
}));
|
|
755
|
+
return {
|
|
756
|
+
...state,
|
|
757
|
+
cellIds: state.cellIds.placeCells(toRestore),
|
|
758
|
+
history: state.history.slice(0, -1),
|
|
759
|
+
scrollKey: cellIds[0] ?? null,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
679
762
|
|
|
680
763
|
const {
|
|
681
764
|
name,
|
|
@@ -684,7 +767,7 @@ const {
|
|
|
684
767
|
index,
|
|
685
768
|
isSetupCell,
|
|
686
769
|
config,
|
|
687
|
-
} =
|
|
770
|
+
} = last;
|
|
688
771
|
|
|
689
772
|
const cellId = isSetupCell ? SETUP_CELL_ID : CellId.create();
|
|
690
773
|
const undoCell = createCell({
|
|
@@ -790,7 +873,7 @@ const {
|
|
|
790
873
|
cellReducer: (cell) => {
|
|
791
874
|
return {
|
|
792
875
|
...cell,
|
|
793
|
-
config: { ...cell.config, ...config },
|
|
876
|
+
config: createCellConfig({ ...cell.config, ...config }),
|
|
794
877
|
};
|
|
795
878
|
},
|
|
796
879
|
});
|
|
@@ -1633,6 +1716,8 @@ export const canUndoDeletesAtom = atom((get) =>
|
|
|
1633
1716
|
canUndoDeletes(get(notebookAtom)),
|
|
1634
1717
|
);
|
|
1635
1718
|
|
|
1719
|
+
export const undoLabelAtom = atom((get) => getUndoLabel(get(notebookAtom)));
|
|
1720
|
+
|
|
1636
1721
|
export const needsRunAtom = atom((get) => notebookNeedsRun(get(notebookAtom)));
|
|
1637
1722
|
|
|
1638
1723
|
export const cellErrorsAtom = atom((get) => {
|
|
@@ -218,13 +218,14 @@ export function toDocumentChanges(
|
|
|
218
218
|
];
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
-
// dropCellOverCell/dropCellOverColumn/moveCellToIndex → set-config + reorder-cells
|
|
221
|
+
// dropCellOverCell/dropCellOverColumn/moveCellToIndex/moveCellsRelativeTo → set-config + reorder-cells
|
|
222
222
|
// Drag-and-drop reorders can move cells within or across columns.
|
|
223
223
|
// We emit config changes for cells whose column changed, then
|
|
224
224
|
// the full ordering.
|
|
225
225
|
case "dropCellOverCell":
|
|
226
226
|
case "dropCellOverColumn":
|
|
227
227
|
case "moveCellToIndex":
|
|
228
|
+
case "moveCellsRelativeTo":
|
|
228
229
|
return columnChanges(prevState, newState);
|
|
229
230
|
|
|
230
231
|
// updateCellCode → set-code
|
|
@@ -302,6 +303,10 @@ export function toDocumentChanges(
|
|
|
302
303
|
case "undoDeleteCell": {
|
|
303
304
|
const changes = newCellChanges(prevState, newState);
|
|
304
305
|
const colChanges = columnChanges(prevState, newState);
|
|
306
|
+
// Undo-cut has no new cells — always emit reorder to sync the move.
|
|
307
|
+
if (changes.length === 0) {
|
|
308
|
+
return colChanges;
|
|
309
|
+
}
|
|
305
310
|
// Only include column changes if layout actually changed
|
|
306
311
|
// (colChanges always has at least a reorder-cells change)
|
|
307
312
|
return colChanges.length > 1 ? [...changes, ...colChanges] : changes;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { atom, useAtomValue } from "jotai";
|
|
4
|
+
import type { ClipboardCellData } from "@/components/editor/navigation/clipboard";
|
|
5
|
+
import type { CellId } from "@/core/cells/ids";
|
|
6
|
+
import { createReducerAndAtoms } from "@/utils/createReducer";
|
|
7
|
+
|
|
8
|
+
interface PendingCutState {
|
|
9
|
+
cellIds: Set<CellId>;
|
|
10
|
+
clipboardData: ClipboardCellData | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const initialState = (): PendingCutState => ({
|
|
14
|
+
cellIds: new Set(),
|
|
15
|
+
clipboardData: null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
valueAtom: pendingCutStateAtom,
|
|
20
|
+
useActions: usePendingCutActionsInternal,
|
|
21
|
+
} = createReducerAndAtoms(initialState, {
|
|
22
|
+
markForCut: (
|
|
23
|
+
_state,
|
|
24
|
+
action: { cellIds: CellId[]; clipboardData: ClipboardCellData },
|
|
25
|
+
) => {
|
|
26
|
+
return {
|
|
27
|
+
cellIds: new Set(action.cellIds),
|
|
28
|
+
clipboardData: action.clipboardData,
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
clear: () => {
|
|
32
|
+
return initialState();
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Re-export the state atom
|
|
37
|
+
export { pendingCutStateAtom };
|
|
38
|
+
|
|
39
|
+
// Derived atom just for cell IDs (for easier consumption)
|
|
40
|
+
export const pendingCutCellIdsAtom = atom(
|
|
41
|
+
(get) => get(pendingCutStateAtom).cellIds,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export const clearPendingCutAtom = atom(null, (_get, set) => {
|
|
45
|
+
set(pendingCutStateAtom, initialState());
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function usePendingCutActions() {
|
|
49
|
+
return usePendingCutActionsInternal();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useIsPendingCut(cellId: CellId): boolean {
|
|
53
|
+
const cellIds = useAtomValue(pendingCutCellIdsAtom);
|
|
54
|
+
return cellIds.has(cellId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function usePendingCutState() {
|
|
58
|
+
return useAtomValue(pendingCutStateAtom);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function useHasPendingCut(): boolean {
|
|
62
|
+
const state = useAtomValue(pendingCutStateAtom);
|
|
63
|
+
return state.cellIds.size > 0;
|
|
64
|
+
}
|
package/src/core/cells/utils.ts
CHANGED
|
@@ -65,6 +65,17 @@ export function canUndoDeletes(state: NotebookState) {
|
|
|
65
65
|
return state.history.length > 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Label for the undo action based on the last history entry type.
|
|
70
|
+
*/
|
|
71
|
+
export function getUndoLabel(state: NotebookState): string {
|
|
72
|
+
const last = state.history[state.history.length - 1];
|
|
73
|
+
if (!last) {
|
|
74
|
+
return "Undo cell deletion";
|
|
75
|
+
}
|
|
76
|
+
return last.type === "move" ? "Undo move" : "Undo cell deletion";
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
/**
|
|
69
80
|
* Get the status of the descendants of the given cell.
|
|
70
81
|
*/
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
} from "@codemirror/view";
|
|
11
11
|
import { createTracebackInfoAtom } from "@/core/cells/cells";
|
|
12
12
|
import { type CellId, HTMLCellId, SCRATCH_CELL_ID } from "@/core/cells/ids";
|
|
13
|
+
import {
|
|
14
|
+
clearPendingCutAtom,
|
|
15
|
+
pendingCutCellIdsAtom,
|
|
16
|
+
} from "@/core/cells/pending-cut-service";
|
|
13
17
|
import { loroSyncAnnotation } from "@/core/codemirror/rtc/loro/sync";
|
|
14
18
|
import type { KeymapConfig } from "@/core/config/config-schema";
|
|
15
19
|
import type { HotkeyProvider } from "@/core/hotkeys/hotkeys";
|
|
@@ -326,6 +330,12 @@ function cellCodeEditing(hotkeys: HotkeyProvider): Extension[] {
|
|
|
326
330
|
code: nextCode,
|
|
327
331
|
formattingChange: isFormattingChange,
|
|
328
332
|
});
|
|
333
|
+
|
|
334
|
+
// Clear pending cut state if this cell was marked for cut
|
|
335
|
+
const pendingCutCellIds = store.get(pendingCutCellIdsAtom);
|
|
336
|
+
if (pendingCutCellIds.has(cellId)) {
|
|
337
|
+
store.set(clearPendingCutAtom);
|
|
338
|
+
}
|
|
329
339
|
}
|
|
330
340
|
});
|
|
331
341
|
|
package/src/css/app/Cell.css
CHANGED
|
@@ -73,6 +73,19 @@
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/* Styling for cells marked for cut */
|
|
77
|
+
&.pending-cut {
|
|
78
|
+
opacity: 0.6;
|
|
79
|
+
border-style: dashed;
|
|
80
|
+
border-color: var(--amber-7);
|
|
81
|
+
|
|
82
|
+
.output-area,
|
|
83
|
+
.cm-gutters,
|
|
84
|
+
.cm {
|
|
85
|
+
background-color: var(--amber-2);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
&:focus-within {
|
|
77
90
|
z-index: 20;
|
|
78
91
|
}
|
|
@@ -957,6 +957,77 @@ describe("MultiColumn", () => {
|
|
|
957
957
|
expect(inserted.topLevelIds[1]).toEqual(["B1", "D1", "B2"]);
|
|
958
958
|
});
|
|
959
959
|
|
|
960
|
+
describe("moveCellsRelativeTo", () => {
|
|
961
|
+
it("moves a single cell before target in same column", () => {
|
|
962
|
+
const moved = multiColumn.moveCellsRelativeTo(["A3"], "A1", "before");
|
|
963
|
+
expect(moved.topLevelIds).toEqual([
|
|
964
|
+
["A3", "A1", "A2"],
|
|
965
|
+
["B1", "B2"],
|
|
966
|
+
["C1", "C2", "C3", "C4"],
|
|
967
|
+
]);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("moves a single cell after target in same column", () => {
|
|
971
|
+
const moved = multiColumn.moveCellsRelativeTo(["A1"], "A3", "after");
|
|
972
|
+
expect(moved.topLevelIds).toEqual([
|
|
973
|
+
["A2", "A3", "A1"],
|
|
974
|
+
["B1", "B2"],
|
|
975
|
+
["C1", "C2", "C3", "C4"],
|
|
976
|
+
]);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("moves multiple cells before target", () => {
|
|
980
|
+
const moved = multiColumn.moveCellsRelativeTo(
|
|
981
|
+
["A1", "A2"],
|
|
982
|
+
"B2",
|
|
983
|
+
"before",
|
|
984
|
+
);
|
|
985
|
+
expect(moved.topLevelIds).toEqual([
|
|
986
|
+
["A3"],
|
|
987
|
+
["B1", "A1", "A2", "B2"],
|
|
988
|
+
["C1", "C2", "C3", "C4"],
|
|
989
|
+
]);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("moves multiple cells after target", () => {
|
|
993
|
+
const moved = multiColumn.moveCellsRelativeTo(
|
|
994
|
+
["A2", "A3"],
|
|
995
|
+
"B1",
|
|
996
|
+
"after",
|
|
997
|
+
);
|
|
998
|
+
expect(moved.topLevelIds).toEqual([
|
|
999
|
+
["A1"],
|
|
1000
|
+
["B1", "A2", "A3", "B2"],
|
|
1001
|
+
["C1", "C2", "C3", "C4"],
|
|
1002
|
+
]);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("returns same MultiColumn when cellIds is empty", () => {
|
|
1006
|
+
const moved = multiColumn.moveCellsRelativeTo([], "A1", "before");
|
|
1007
|
+
expect(moved).toBe(multiColumn);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("inserts at end when target is among moved cells", () => {
|
|
1011
|
+
const moved = multiColumn.moveCellsRelativeTo(
|
|
1012
|
+
["A1", "A2"],
|
|
1013
|
+
"A2",
|
|
1014
|
+
"after",
|
|
1015
|
+
);
|
|
1016
|
+
// Target A2 was removed; insert at end of column 0
|
|
1017
|
+
expect(moved.topLevelIds).toEqual([
|
|
1018
|
+
["A3", "A1", "A2"],
|
|
1019
|
+
["B1", "B2"],
|
|
1020
|
+
["C1", "C2", "C3", "C4"],
|
|
1021
|
+
]);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("throws when node not found", () => {
|
|
1025
|
+
expect(() =>
|
|
1026
|
+
multiColumn.moveCellsRelativeTo(["Z1"], "A1", "before"),
|
|
1027
|
+
).toThrow("Cell Z1 not found in any column");
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
|
|
960
1031
|
it("deletes by id", () => {
|
|
961
1032
|
const deleted = multiColumn.deleteById("B1");
|
|
962
1033
|
expect(deleted.topLevelIds[1]).toEqual(["B2"]);
|
package/src/utils/id-tree.tsx
CHANGED
|
@@ -861,6 +861,67 @@ export class MultiColumn<T> {
|
|
|
861
861
|
return new MultiColumn(newColumns);
|
|
862
862
|
}
|
|
863
863
|
|
|
864
|
+
/**
|
|
865
|
+
* Move multiple cells to be immediately before (or after) a target cell.
|
|
866
|
+
* Cells are inserted in their original order.
|
|
867
|
+
*
|
|
868
|
+
* @throws Error if any cellId is not found in any column.
|
|
869
|
+
* If targetId is among the moved cells, cells are inserted at the end of the target column.
|
|
870
|
+
*/
|
|
871
|
+
moveCellsRelativeTo(
|
|
872
|
+
cellIds: T[],
|
|
873
|
+
targetId: T,
|
|
874
|
+
position: "before" | "after",
|
|
875
|
+
): MultiColumn<T> {
|
|
876
|
+
if (cellIds.length === 0) {
|
|
877
|
+
return this;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const cellIdSet = new Set(cellIds);
|
|
881
|
+
const targetColumn = this.findWithId(targetId);
|
|
882
|
+
const targetColIndex = this.indexOfOrThrow(targetColumn.id);
|
|
883
|
+
|
|
884
|
+
// Collect nodes to move
|
|
885
|
+
const nodesToMove: TreeNode<T>[] = [];
|
|
886
|
+
for (const id of cellIds) {
|
|
887
|
+
const col = this.findWithId(id);
|
|
888
|
+
const node = col.nodes.find((n) => n.value === id);
|
|
889
|
+
if (!node) {
|
|
890
|
+
throw new Error(`Node ${id} not found in column ${col.id}`);
|
|
891
|
+
}
|
|
892
|
+
nodesToMove.push(node);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Remove moved cells from all columns
|
|
896
|
+
const columnsWithRemovals = this.columns.map((col) =>
|
|
897
|
+
col.withNodes(col.nodes.filter((n) => !cellIdSet.has(n.value))),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
// Find target index in the cleaned column
|
|
901
|
+
const cleanedTargetCol = columnsWithRemovals[targetColIndex];
|
|
902
|
+
let insertIndex = cleanedTargetCol.nodes.findIndex(
|
|
903
|
+
(n) => n.value === targetId,
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
// If target was one of the moved cells, insert at end
|
|
907
|
+
if (insertIndex === -1) {
|
|
908
|
+
insertIndex = cleanedTargetCol.nodes.length;
|
|
909
|
+
} else if (position === "after") {
|
|
910
|
+
insertIndex += 1;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Insert all moved nodes at target position
|
|
914
|
+
const newTargetNodes = arrayInsertMany(
|
|
915
|
+
cleanedTargetCol.nodes,
|
|
916
|
+
insertIndex,
|
|
917
|
+
nodesToMove,
|
|
918
|
+
);
|
|
919
|
+
columnsWithRemovals[targetColIndex] =
|
|
920
|
+
cleanedTargetCol.withNodes(newTargetNodes);
|
|
921
|
+
|
|
922
|
+
return new MultiColumn(columnsWithRemovals);
|
|
923
|
+
}
|
|
924
|
+
|
|
864
925
|
indexOfOrThrow(id: CellColumnId): number {
|
|
865
926
|
const index = this.columns.findIndex((c) => c.id === id);
|
|
866
927
|
if (index === -1) {
|
|
@@ -963,6 +1024,34 @@ export class MultiColumn<T> {
|
|
|
963
1024
|
return this.transformWithCellId(cellId, (c) => c.delete(cellId));
|
|
964
1025
|
}
|
|
965
1026
|
|
|
1027
|
+
/**
|
|
1028
|
+
* Remove the given cells from the tree, then re-insert each at its
|
|
1029
|
+
* (columnId, index). Used to undo a move (e.g. cut-paste) by restoring
|
|
1030
|
+
* cells to their previous positions. Placements are sorted by
|
|
1031
|
+
* (columnId, index) so insert order keeps indices valid.
|
|
1032
|
+
*/
|
|
1033
|
+
placeCells(
|
|
1034
|
+
placements: Array<{ id: T; columnId: CellColumnId; index: CellIndex }>,
|
|
1035
|
+
): MultiColumn<T> {
|
|
1036
|
+
if (placements.length === 0) {
|
|
1037
|
+
return this;
|
|
1038
|
+
}
|
|
1039
|
+
let result = this.deleteById(placements[0].id);
|
|
1040
|
+
for (const { id } of placements.slice(1)) {
|
|
1041
|
+
result = result.deleteById(id);
|
|
1042
|
+
}
|
|
1043
|
+
const sorted = [...placements].toSorted((a, b) => {
|
|
1044
|
+
if (a.columnId !== b.columnId) {
|
|
1045
|
+
return a.columnId.localeCompare(b.columnId);
|
|
1046
|
+
}
|
|
1047
|
+
return (a.index as number) - (b.index as number);
|
|
1048
|
+
});
|
|
1049
|
+
for (const { id, columnId, index } of sorted) {
|
|
1050
|
+
result = result.insertId(id, columnId, index);
|
|
1051
|
+
}
|
|
1052
|
+
return result;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
966
1055
|
compact(): MultiColumn<T> {
|
|
967
1056
|
// Don't compact if there's only one column
|
|
968
1057
|
if (this.columns.length === 1) {
|