@marimo-team/islands 0.18.5-dev168 → 0.18.5-dev171
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/{Combination-CoheOLQf.js → Combination-33P1MEPK.js} +1 -1
- package/dist/{ConnectedDataExplorerComponent-BhnOd8mV.js → ConnectedDataExplorerComponent-BIfUtj_S.js} +9 -9
- package/dist/{any-language-editor-CO_tO4mX.js → any-language-editor-Bda9cY1_.js} +4 -4
- package/dist/{button-BE_o5IpN.js → button-BlF-78eJ.js} +1 -1
- package/dist/{check-Crt1N6cj.js → check-DDykH_Yi.js} +1 -1
- package/dist/{copy-BmWLlwa6.js → copy-B5nooU3m.js} +2 -2
- package/dist/{error-banner-DyX88bLT.js → error-banner-UH0Nxilf.js} +3 -3
- package/dist/{esm-CiSvoGHk.js → esm-D197NGQX.js} +4 -4
- package/dist/{glide-data-editor-tS-A6Szz.js → glide-data-editor-DWlk0mEY.js} +7 -7
- package/dist/{hotkeys-BUVs9ecz.js → hotkeys-C4e3s3sJ.js} +2 -2
- package/dist/{label-C3TPGdQ0.js → label-oKuiQuiM.js} +4 -4
- package/dist/{loader-Cn9P1Cko.js → loader-DH7xXi-E.js} +1 -1
- package/dist/main.js +113 -61
- package/dist/{mermaid-BAHK5egT.js → mermaid-JA6veDHv.js} +3 -3
- package/dist/{slides-component-oQmowhoJ.js → slides-component-BNbVrOMb.js} +2 -2
- package/dist/{spec-D-_Yj0lh.js → spec-hsYzGr6F.js} +5 -5
- package/dist/style.css +1 -1
- package/dist/{types-CJDsYooe.js → types-DEmfj_i8.js} +6 -6
- package/dist/{useAsyncData-CaouoMw5.js → useAsyncData-BGpae_uu.js} +1 -1
- package/dist/{useDeepCompareMemoize-B01JaKw2.js → useDeepCompareMemoize-D3uOrgqD.js} +5 -5
- package/dist/{useIframeCapabilities-oYhPeWtR.js → useIframeCapabilities-BsIPDupA.js} +1 -1
- package/dist/{useTheme-DLCDAdUO.js → useTheme-DdLjooMf.js} +1 -1
- package/dist/{vega-component-D36WQQq8.js → vega-component-C1FaaACt.js} +8 -8
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +1 -1
- package/src/components/editor/chrome/panels/panel-context.tsx +34 -0
- package/src/components/editor/chrome/state.ts +30 -15
- package/src/components/editor/chrome/types.ts +67 -77
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +216 -139
- package/src/components/editor/chrome/wrapper/sidebar.tsx +76 -43
- package/src/components/scratchpad/scratchpad.tsx +17 -4
- package/src/components/ui/reorderable-list.tsx +190 -31
- package/src/core/codemirror/cells/extensions.ts +7 -4
- package/src/core/hotkeys/__tests__/shortcuts.test.ts +61 -4
- package/src/core/hotkeys/shortcuts.ts +34 -2
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import type React from "react";
|
|
4
4
|
import { useMemo } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type DropItem,
|
|
7
|
+
ListBox,
|
|
8
|
+
ListBoxItem,
|
|
9
|
+
useDragAndDrop,
|
|
10
|
+
} from "react-aria-components";
|
|
6
11
|
import { Logger } from "@/utils/Logger";
|
|
7
12
|
import {
|
|
8
13
|
ContextMenu,
|
|
@@ -12,15 +17,37 @@ import {
|
|
|
12
17
|
} from "./context-menu";
|
|
13
18
|
import "./reorderable-list.css";
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
interface DragData<T> {
|
|
21
|
+
itemId: string;
|
|
22
|
+
sourceListId: string;
|
|
23
|
+
item: T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDragMimeType(dragType: string): string {
|
|
27
|
+
return `application/x-reorderable-${dragType}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseDragData<T>(text: string): DragData<T> | null {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text) as DragData<T>;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReorderableListProps<T> {
|
|
16
39
|
/**
|
|
17
|
-
* The current list of items
|
|
40
|
+
* The current list of items.
|
|
18
41
|
*/
|
|
19
42
|
value: T[];
|
|
20
43
|
/**
|
|
21
44
|
* Callback when items are reordered
|
|
22
45
|
*/
|
|
23
46
|
setValue: (items: T[]) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Function to get a unique key for each item. Used for drag-drop and rendering.
|
|
49
|
+
*/
|
|
50
|
+
getKey: (item: T) => string;
|
|
24
51
|
/**
|
|
25
52
|
* Render function for each item.
|
|
26
53
|
* Note: Avoid interactive elements (buttons) inside - they break drag behavior.
|
|
@@ -50,49 +77,148 @@ export interface ReorderableListProps<T extends { id: string | number }> {
|
|
|
50
77
|
* Additional class name for the list container
|
|
51
78
|
*/
|
|
52
79
|
className?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Configuration for cross-list drag-drop. When set, items can be dragged
|
|
82
|
+
* between lists that share the same `dragType`.
|
|
83
|
+
*/
|
|
84
|
+
crossListDrag?: {
|
|
85
|
+
/** Identifier that links lists together - same dragType = can share items */
|
|
86
|
+
dragType: string;
|
|
87
|
+
/** Unique identifier for this list */
|
|
88
|
+
listId: string;
|
|
89
|
+
/**
|
|
90
|
+
* Callback when an item is received from another list.
|
|
91
|
+
* At this point, setValue has been called with the new item included,
|
|
92
|
+
* but the parent component may not have re-rendered yet.
|
|
93
|
+
* Use this to remove the item from the source list and handle any side effects.
|
|
94
|
+
*/
|
|
95
|
+
onReceive: (item: T, fromListId: string, insertIndex: number) => void;
|
|
96
|
+
};
|
|
53
97
|
}
|
|
54
98
|
|
|
55
99
|
/**
|
|
56
|
-
* A generic reorderable list component using react-aria-components
|
|
57
|
-
* Items can be reordered via drag and drop.
|
|
100
|
+
* A generic reorderable list component using react-aria-components.
|
|
101
|
+
* Items can be reordered via drag and drop within the list.
|
|
102
|
+
*
|
|
103
|
+
* For cross-list drag-drop, set the same `dragType` on multiple lists
|
|
104
|
+
* and provide an `onReceive` callback to handle items dropped from other lists.
|
|
58
105
|
*
|
|
59
106
|
* @example
|
|
60
107
|
* ```tsx
|
|
61
|
-
*
|
|
62
|
-
* id: string;
|
|
63
|
-
* name: string;
|
|
64
|
-
* }
|
|
65
|
-
*
|
|
66
|
-
* const [items, setItems] = useState<MyItem[]>([...]);
|
|
67
|
-
*
|
|
108
|
+
* // Single list reordering
|
|
68
109
|
* <ReorderableList
|
|
69
110
|
* value={items}
|
|
70
111
|
* setValue={setItems}
|
|
112
|
+
* getKey={(item) => item.id}
|
|
71
113
|
* renderItem={(item) => <div>{item.name}</div>}
|
|
72
|
-
*
|
|
114
|
+
* />
|
|
115
|
+
*
|
|
116
|
+
* // Cross-list drag-drop
|
|
117
|
+
* <ReorderableList
|
|
118
|
+
* value={sidebarItems}
|
|
119
|
+
* setValue={setSidebarItems}
|
|
120
|
+
* getKey={(item) => item.type}
|
|
121
|
+
* renderItem={(item) => <div>{item.name}</div>}
|
|
122
|
+
* crossListDrag={{
|
|
123
|
+
* dragType: "panels",
|
|
124
|
+
* listId: "sidebar",
|
|
125
|
+
* onReceive: (item, fromListId) => {
|
|
126
|
+
* // Remove from source list
|
|
127
|
+
* setOtherItems(prev => prev.filter(i => i.type !== item.type));
|
|
128
|
+
* },
|
|
129
|
+
* }}
|
|
73
130
|
* />
|
|
74
131
|
* ```
|
|
75
132
|
*/
|
|
76
|
-
export const ReorderableList = <T extends
|
|
133
|
+
export const ReorderableList = <T extends object>({
|
|
77
134
|
value,
|
|
78
135
|
setValue,
|
|
136
|
+
getKey,
|
|
79
137
|
renderItem,
|
|
80
138
|
onAction,
|
|
81
139
|
availableItems,
|
|
82
|
-
getItemLabel
|
|
140
|
+
getItemLabel,
|
|
83
141
|
minItems = 1,
|
|
84
142
|
ariaLabel = "Reorderable list",
|
|
85
143
|
className,
|
|
144
|
+
crossListDrag,
|
|
86
145
|
}: ReorderableListProps<T>) => {
|
|
146
|
+
const mimeType = crossListDrag
|
|
147
|
+
? getDragMimeType(crossListDrag.dragType)
|
|
148
|
+
: null;
|
|
149
|
+
const onReceive = crossListDrag?.onReceive;
|
|
150
|
+
|
|
151
|
+
// Shared handler for cross-list drops
|
|
152
|
+
const handleCrossListDrop = async (
|
|
153
|
+
items: DropItem[],
|
|
154
|
+
insertIndex: number,
|
|
155
|
+
) => {
|
|
156
|
+
if (!mimeType || !crossListDrag?.listId || !onReceive) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const dragItem of items) {
|
|
161
|
+
if (dragItem.kind !== "text" || !dragItem.types.has(mimeType)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const text = await dragItem.getText(mimeType);
|
|
166
|
+
const data = parseDragData<T>(text);
|
|
167
|
+
if (!data) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only accept drops from different lists
|
|
172
|
+
if (data.sourceListId === crossListDrag.listId) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Skip if item already exists in this list
|
|
177
|
+
if (value.some((item) => getKey(item) === getKey(data.item))) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Add to this list and notify parent
|
|
182
|
+
setValue([
|
|
183
|
+
...value.slice(0, insertIndex),
|
|
184
|
+
data.item,
|
|
185
|
+
...value.slice(insertIndex),
|
|
186
|
+
]);
|
|
187
|
+
onReceive(data.item, data.sourceListId, insertIndex);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
87
191
|
const { dragAndDropHooks } = useDragAndDrop<T>({
|
|
88
|
-
getItems: (keys) =>
|
|
192
|
+
getItems: (keys) =>
|
|
193
|
+
[...keys].map((key) => {
|
|
194
|
+
const item = value.find((i) => getKey(i) === key);
|
|
195
|
+
const baseData: Record<string, string> = {
|
|
196
|
+
"text/plain": String(key),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Add cross-list drag data if dragType is set
|
|
200
|
+
if (mimeType && crossListDrag?.listId && item) {
|
|
201
|
+
const dragData: DragData<T> = {
|
|
202
|
+
itemId: String(key),
|
|
203
|
+
sourceListId: crossListDrag.listId,
|
|
204
|
+
item,
|
|
205
|
+
};
|
|
206
|
+
baseData[mimeType] = JSON.stringify(dragData);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return baseData;
|
|
210
|
+
}),
|
|
211
|
+
|
|
212
|
+
// Accept drops from lists with the same dragType
|
|
213
|
+
acceptedDragTypes: mimeType ? [mimeType, "text/plain"] : ["text/plain"],
|
|
214
|
+
|
|
89
215
|
onReorder(e) {
|
|
90
216
|
const keySet = new Set(e.keys);
|
|
91
|
-
const draggedItems = value.filter((item) => keySet.has(item
|
|
92
|
-
const remaining = value.filter((item) => !keySet.has(item
|
|
217
|
+
const draggedItems = value.filter((item) => keySet.has(getKey(item)));
|
|
218
|
+
const remaining = value.filter((item) => !keySet.has(getKey(item)));
|
|
93
219
|
|
|
94
220
|
const targetIndex = remaining.findIndex(
|
|
95
|
-
(item) => item
|
|
221
|
+
(item) => getKey(item) === e.target.key,
|
|
96
222
|
);
|
|
97
223
|
const insertIndex =
|
|
98
224
|
e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
|
|
@@ -103,19 +229,34 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
103
229
|
...remaining.slice(insertIndex),
|
|
104
230
|
]);
|
|
105
231
|
},
|
|
232
|
+
|
|
233
|
+
// Handle drops from other lists (on a specific item)
|
|
234
|
+
async onInsert(e) {
|
|
235
|
+
const targetIndex = value.findIndex(
|
|
236
|
+
(item) => getKey(item) === e.target.key,
|
|
237
|
+
);
|
|
238
|
+
const insertIndex =
|
|
239
|
+
e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
|
|
240
|
+
await handleCrossListDrop(e.items, insertIndex);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Handle drops on empty list or root
|
|
244
|
+
async onRootDrop(e) {
|
|
245
|
+
await handleCrossListDrop(e.items, value.length);
|
|
246
|
+
},
|
|
106
247
|
});
|
|
107
248
|
|
|
108
249
|
// Track which items are currently in the list
|
|
109
|
-
const
|
|
110
|
-
() => new Set(value.map((item) => item
|
|
111
|
-
[value],
|
|
250
|
+
const currentItemKeys = useMemo(
|
|
251
|
+
() => new Set(value.map((item) => getKey(item))),
|
|
252
|
+
[value, getKey],
|
|
112
253
|
);
|
|
113
254
|
|
|
114
255
|
const handleToggleItem = (item: T, isChecked: boolean) => {
|
|
115
256
|
if (isChecked) {
|
|
116
257
|
setValue([...value, item]);
|
|
117
258
|
} else if (value.length > minItems) {
|
|
118
|
-
setValue(value.filter((v) => v
|
|
259
|
+
setValue(value.filter((v) => getKey(v) !== getKey(item)));
|
|
119
260
|
}
|
|
120
261
|
};
|
|
121
262
|
|
|
@@ -124,12 +265,12 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
124
265
|
return;
|
|
125
266
|
}
|
|
126
267
|
|
|
127
|
-
const item = value.find((i) => i
|
|
268
|
+
const item = value.find((i) => getKey(i) === key);
|
|
128
269
|
|
|
129
270
|
if (!item) {
|
|
130
271
|
Logger.warn("handleAction: item not found for key", {
|
|
131
272
|
key,
|
|
132
|
-
|
|
273
|
+
availableKeys: value.map((v) => getKey(v)),
|
|
133
274
|
});
|
|
134
275
|
return;
|
|
135
276
|
}
|
|
@@ -137,19 +278,36 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
137
278
|
onAction(item);
|
|
138
279
|
};
|
|
139
280
|
|
|
281
|
+
// When list is empty, show a drop zone placeholder
|
|
282
|
+
const isEmpty = value.length === 0;
|
|
283
|
+
|
|
140
284
|
const listBox = (
|
|
141
285
|
<ListBox
|
|
142
286
|
aria-label={ariaLabel}
|
|
143
287
|
selectionMode="none"
|
|
144
|
-
items={value}
|
|
145
288
|
dragAndDropHooks={dragAndDropHooks}
|
|
146
289
|
className={className}
|
|
147
290
|
onAction={handleAction}
|
|
148
291
|
>
|
|
149
|
-
{(item) => (
|
|
150
|
-
<ListBoxItem
|
|
292
|
+
{value.map((item) => (
|
|
293
|
+
<ListBoxItem
|
|
294
|
+
key={getKey(item)}
|
|
295
|
+
id={getKey(item)}
|
|
296
|
+
className="active:cursor-grabbing data-[dragging]:opacity-60 outline-none"
|
|
297
|
+
>
|
|
151
298
|
{renderItem(item)}
|
|
152
299
|
</ListBoxItem>
|
|
300
|
+
))}
|
|
301
|
+
{/*
|
|
302
|
+
* When the list is empty, render an invisible placeholder item.
|
|
303
|
+
* This ensures the ListBox maintains minimum dimensions so users can:
|
|
304
|
+
* 1. Right-click to access the context menu and add items back
|
|
305
|
+
* 2. Drag items from another list into this empty list
|
|
306
|
+
*/}
|
|
307
|
+
{isEmpty && (
|
|
308
|
+
<ListBoxItem id="__empty__" className="min-h-[40px] min-w-[40px]">
|
|
309
|
+
<span />
|
|
310
|
+
</ListBoxItem>
|
|
153
311
|
)}
|
|
154
312
|
</ListBox>
|
|
155
313
|
);
|
|
@@ -164,19 +322,20 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
164
322
|
<ContextMenuTrigger asChild={true}>{listBox}</ContextMenuTrigger>
|
|
165
323
|
<ContextMenuContent>
|
|
166
324
|
{availableItems.map((item) => {
|
|
167
|
-
const
|
|
325
|
+
const key = getKey(item);
|
|
326
|
+
const isChecked = currentItemKeys.has(key);
|
|
168
327
|
const isDisabled = isChecked && value.length <= minItems;
|
|
169
328
|
|
|
170
329
|
return (
|
|
171
330
|
<ContextMenuCheckboxItem
|
|
172
|
-
key={
|
|
331
|
+
key={key}
|
|
173
332
|
checked={isChecked}
|
|
174
333
|
disabled={isDisabled}
|
|
175
334
|
onCheckedChange={(checked) => {
|
|
176
335
|
handleToggleItem(item, checked);
|
|
177
336
|
}}
|
|
178
337
|
>
|
|
179
|
-
{getItemLabel(item)}
|
|
338
|
+
{getItemLabel ? getItemLabel(item) : key}
|
|
180
339
|
</ContextMenuCheckboxItem>
|
|
181
340
|
);
|
|
182
341
|
})}
|
|
@@ -7,6 +7,7 @@ import { createTracebackInfoAtom } from "@/core/cells/cells";
|
|
|
7
7
|
import { type CellId, HTMLCellId, SCRATCH_CELL_ID } from "@/core/cells/ids";
|
|
8
8
|
import type { KeymapConfig } from "@/core/config/config-schema";
|
|
9
9
|
import type { HotkeyProvider } from "@/core/hotkeys/hotkeys";
|
|
10
|
+
import { duplicateWithCtrlModifier } from "@/core/hotkeys/shortcuts";
|
|
10
11
|
import { store } from "@/core/state/jotai";
|
|
11
12
|
import { createObservable } from "@/core/state/observable";
|
|
12
13
|
import { formatKeymapExtension } from "../extensions";
|
|
@@ -33,8 +34,9 @@ function cellKeymaps({
|
|
|
33
34
|
}): Extension[] {
|
|
34
35
|
const keybindings: KeyBinding[] = [];
|
|
35
36
|
|
|
37
|
+
// Run-related keybindings get Ctrl equivalents on macOS for Jupyter/Colab users
|
|
36
38
|
keybindings.push(
|
|
37
|
-
{
|
|
39
|
+
...duplicateWithCtrlModifier({
|
|
38
40
|
key: hotkeys.getHotkey("cell.run").key,
|
|
39
41
|
preventDefault: true,
|
|
40
42
|
stopPropagation: true,
|
|
@@ -43,8 +45,9 @@ function cellKeymaps({
|
|
|
43
45
|
actions.onRun();
|
|
44
46
|
return true;
|
|
45
47
|
},
|
|
46
|
-
},
|
|
48
|
+
}),
|
|
47
49
|
{
|
|
50
|
+
// Shift-Enter has no Cmd, so no Ctrl equivalent needed
|
|
48
51
|
key: hotkeys.getHotkey("cell.runAndNewBelow").key,
|
|
49
52
|
preventDefault: true,
|
|
50
53
|
stopPropagation: true,
|
|
@@ -59,7 +62,7 @@ function cellKeymaps({
|
|
|
59
62
|
return true;
|
|
60
63
|
},
|
|
61
64
|
},
|
|
62
|
-
{
|
|
65
|
+
...duplicateWithCtrlModifier({
|
|
63
66
|
key: hotkeys.getHotkey("cell.runAndNewAbove").key,
|
|
64
67
|
preventDefault: true,
|
|
65
68
|
stopPropagation: true,
|
|
@@ -73,7 +76,7 @@ function cellKeymaps({
|
|
|
73
76
|
actions.moveToNextCell({ cellId, before: true });
|
|
74
77
|
return true;
|
|
75
78
|
},
|
|
76
|
-
},
|
|
79
|
+
}),
|
|
77
80
|
{
|
|
78
81
|
key: hotkeys.getHotkey("cell.aiCompletion").key,
|
|
79
82
|
preventDefault: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { parseShortcut } from "../shortcuts";
|
|
3
|
+
import { duplicateWithCtrlModifier, parseShortcut } from "../shortcuts";
|
|
4
4
|
|
|
5
5
|
describe("parseShortcut", () => {
|
|
6
6
|
it("should recognize single key shortcuts", () => {
|
|
@@ -21,10 +21,14 @@ describe("parseShortcut", () => {
|
|
|
21
21
|
expect(shortcut(event)).toBe(true);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it("should recognize combined Cmd key shortcuts", () => {
|
|
24
|
+
it("should recognize combined Cmd key shortcuts with meta or ctrl", () => {
|
|
25
25
|
const shortcut = parseShortcut("Cmd-a");
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Cmd should accept both metaKey and ctrlKey (like Mod)
|
|
27
|
+
const metaEvent = new KeyboardEvent("keydown", { key: "a", metaKey: true });
|
|
28
|
+
expect(shortcut(metaEvent)).toBe(true);
|
|
29
|
+
|
|
30
|
+
const ctrlEvent = new KeyboardEvent("keydown", { key: "a", ctrlKey: true });
|
|
31
|
+
expect(shortcut(ctrlEvent)).toBe(true);
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
it("should recognize Arrow key shortcuts", () => {
|
|
@@ -164,3 +168,56 @@ describe("parseShortcut", () => {
|
|
|
164
168
|
expect(parseShortcut("Ctrl+A")(event)).toBe(false);
|
|
165
169
|
});
|
|
166
170
|
});
|
|
171
|
+
|
|
172
|
+
describe("duplicateWithCtrlModifier", () => {
|
|
173
|
+
it("should duplicate Cmd binding with Ctrl variant on macOS", () => {
|
|
174
|
+
// Mock macOS platform
|
|
175
|
+
vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
|
|
176
|
+
|
|
177
|
+
const binding = { key: "Cmd-Enter", run: () => true };
|
|
178
|
+
const result = duplicateWithCtrlModifier(binding);
|
|
179
|
+
|
|
180
|
+
expect(result).toHaveLength(2);
|
|
181
|
+
expect(result[0].key).toBe("Cmd-Enter");
|
|
182
|
+
expect(result[1].key).toBe("Ctrl-Enter");
|
|
183
|
+
|
|
184
|
+
vi.restoreAllMocks();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should not duplicate binding without Cmd", () => {
|
|
188
|
+
vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
|
|
189
|
+
|
|
190
|
+
const binding = { key: "Shift-Enter", run: () => true };
|
|
191
|
+
const result = duplicateWithCtrlModifier(binding);
|
|
192
|
+
|
|
193
|
+
expect(result).toHaveLength(1);
|
|
194
|
+
expect(result[0].key).toBe("Shift-Enter");
|
|
195
|
+
|
|
196
|
+
vi.restoreAllMocks();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("should not duplicate Cmd-Ctrl binding to avoid Ctrl-Ctrl", () => {
|
|
200
|
+
vi.spyOn(window.navigator, "platform", "get").mockReturnValue("MacIntel");
|
|
201
|
+
|
|
202
|
+
const binding = { key: "Cmd-Ctrl-Enter", run: () => true };
|
|
203
|
+
const result = duplicateWithCtrlModifier(binding);
|
|
204
|
+
|
|
205
|
+
// Should NOT create a Ctrl-Ctrl-Enter variant
|
|
206
|
+
expect(result).toHaveLength(1);
|
|
207
|
+
expect(result[0].key).toBe("Cmd-Ctrl-Enter");
|
|
208
|
+
|
|
209
|
+
vi.restoreAllMocks();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("should not duplicate on non-macOS platforms", () => {
|
|
213
|
+
vi.spyOn(window.navigator, "platform", "get").mockReturnValue("Win32");
|
|
214
|
+
|
|
215
|
+
const binding = { key: "Cmd-Enter", run: () => true };
|
|
216
|
+
const result = duplicateWithCtrlModifier(binding);
|
|
217
|
+
|
|
218
|
+
expect(result).toHaveLength(1);
|
|
219
|
+
expect(result[0].key).toBe("Cmd-Enter");
|
|
220
|
+
|
|
221
|
+
vi.restoreAllMocks();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
+
import type { KeyBinding } from "@codemirror/view";
|
|
3
4
|
import { Logger } from "@/utils/Logger";
|
|
4
5
|
import { NOT_SET } from "./hotkeys";
|
|
5
6
|
|
|
@@ -106,8 +107,8 @@ function areKeysPressed(keys: string[], e: IKeyboardEvent): boolean {
|
|
|
106
107
|
function normalizeKey(key: string): string {
|
|
107
108
|
const specialKeys: { [key: string]: string } = {
|
|
108
109
|
control: "ctrl",
|
|
109
|
-
command: "
|
|
110
|
-
cmd: "
|
|
110
|
+
command: "mod",
|
|
111
|
+
cmd: "mod",
|
|
111
112
|
option: "alt",
|
|
112
113
|
return: "enter",
|
|
113
114
|
};
|
|
@@ -144,3 +145,34 @@ export function resolvePlatform(): Platform {
|
|
|
144
145
|
}
|
|
145
146
|
return "linux";
|
|
146
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* On macOS, duplicate a Cmd-based keybinding to also work with Ctrl.
|
|
151
|
+
* This allows users coming from Jupyter/Colab to use Ctrl-Enter to run cells.
|
|
152
|
+
*
|
|
153
|
+
* Returns an array with the original binding, plus a Ctrl variant on macOS.
|
|
154
|
+
* For use with CodeMirror keymap bindings.
|
|
155
|
+
*
|
|
156
|
+
* Design decision: User-defined Cmd shortcuts also get Ctrl equivalents.
|
|
157
|
+
* The edge case is if a user wants `Cmd+<x>` and `Ctrl+<x>` to trigger
|
|
158
|
+
* different actions, this isn't currently supported. Given the relatively
|
|
159
|
+
* small number of keymaps, we're keeping this simple. If it becomes an issue,
|
|
160
|
+
* we can refactor to resolve a special "Mod" key internally and require users
|
|
161
|
+
* to specify explicit single-key mappings.
|
|
162
|
+
*
|
|
163
|
+
* Note: If the binding already contains Ctrl (e.g., Cmd-Ctrl-Enter),
|
|
164
|
+
* no duplication is done to avoid producing invalid Ctrl-Ctrl-key combos.
|
|
165
|
+
*/
|
|
166
|
+
export function duplicateWithCtrlModifier<T extends KeyBinding>(
|
|
167
|
+
binding: T,
|
|
168
|
+
): T[] {
|
|
169
|
+
// Skip if not macOS, not a Cmd binding, or already has Ctrl
|
|
170
|
+
if (
|
|
171
|
+
!isPlatformMac() ||
|
|
172
|
+
!binding.key?.includes("Cmd") ||
|
|
173
|
+
binding.key.includes("Ctrl")
|
|
174
|
+
) {
|
|
175
|
+
return [binding];
|
|
176
|
+
}
|
|
177
|
+
return [binding, { ...binding, key: binding.key.replaceAll("Cmd", "Ctrl") }];
|
|
178
|
+
}
|