@oceanum/oceanumlab 4.7.2 → 4.9.0

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.
@@ -189,6 +189,49 @@ export function buildSpecBody(
189
189
  return { ok: false, bytes: strippedBytes };
190
190
  }
191
191
 
192
+ /**
193
+ * The notebook stored in a record, untrusted. Throws if the record does not hold an
194
+ * nbformat 4 notebook.
195
+ */
196
+ function recordNotebook(record: ISpecRecord): INotebookContent {
197
+ const spec = record.spec;
198
+ if (!isNotebook(spec)) {
199
+ throw new Error('This Oceanum.io record is not a valid notebook.');
200
+ }
201
+ return withoutTrust(spec);
202
+ }
203
+
204
+ function isNotebook(value: unknown): value is INotebookContent {
205
+ return (
206
+ isObject(value) &&
207
+ value.nbformat === 4 &&
208
+ Array.isArray(value.cells) &&
209
+ value.cells.every(
210
+ cell => isObject(cell) && typeof cell.cell_type === 'string'
211
+ ) &&
212
+ isObject(value.metadata)
213
+ );
214
+ }
215
+
216
+ /**
217
+ * A notebook uploaded from the user's computer, untrusted and linked to nothing: a file
218
+ * someone else downloaded from a record must not save over that record, and its outputs
219
+ * must not run scripts on the origin that holds the Oceanum.io session. Throws if the
220
+ * text is not an nbformat 4 notebook; the message never quotes the file.
221
+ */
222
+ export function notebookFromUpload(text: string): INotebookContent {
223
+ let parsed: unknown;
224
+ try {
225
+ parsed = JSON.parse(text);
226
+ } catch {
227
+ throw new Error('The file is not a Jupyter notebook: it is not JSON.');
228
+ }
229
+ if (!isNotebook(parsed)) {
230
+ throw new Error('The file is not a Jupyter notebook in nbformat 4.');
231
+ }
232
+ return withoutLink(withoutTrust(parsed));
233
+ }
234
+
192
235
  /**
193
236
  * The notebook stored in a record, with its `oceanum` link set. Throws if the record
194
237
  * does not hold an nbformat 4 notebook.
@@ -197,26 +240,28 @@ export function notebookFromRecord(
197
240
  record: ISpecRecord,
198
241
  specsUrl: string
199
242
  ): INotebookContent {
200
- const spec = record.spec;
201
- if (
202
- !isObject(spec) ||
203
- spec.nbformat !== 4 ||
204
- !Array.isArray(spec.cells) ||
205
- !isObject(spec.metadata)
206
- ) {
207
- throw new Error('This Oceanum.io record is not a valid notebook.');
208
- }
209
243
  const link: IOceanumLink = { spec_id: record.id, specs_url: specsUrl };
210
244
  if (record.description) {
211
245
  link.description = record.description;
212
246
  }
213
- const content = withoutTrust(spec as unknown as INotebookContent);
247
+ const content = recordNotebook(record);
214
248
  return {
215
249
  ...content,
216
250
  metadata: { ...content.metadata, [METADATA_KEY]: { ...link } }
217
251
  };
218
252
  }
219
253
 
254
+ /**
255
+ * A copy of the notebook stored in a record, linked to nothing: untrusted, and without
256
+ * any `oceanum` link, so saving it creates a record of the user's own instead of
257
+ * writing to this one. Throws if the record does not hold an nbformat 4 notebook.
258
+ */
259
+ export function unlinkedNotebookFromRecord(
260
+ record: ISpecRecord
261
+ ): INotebookContent {
262
+ return withoutLink(recordNotebook(record));
263
+ }
264
+
220
265
  /** Make a record name safe to use as a single path segment. */
221
266
  export function sanitizeName(name: string): string {
222
267
  const clean = name
@@ -307,6 +352,39 @@ export function partitionSummaries(
307
352
  };
308
353
  }
309
354
 
355
+ /** The spec store type of the Notebook Demo specs, which are the Examples. */
356
+ export const NOTEBOOK_DEMO_TYPE = 'notebook-demo';
357
+
358
+ /** One example: a Notebook Demo spec, whose body is the example notebook. */
359
+ export interface INotebookDemoItem {
360
+ id: string;
361
+ title: string;
362
+ summary?: string;
363
+ }
364
+
365
+ /**
366
+ * The examples in a listing of Notebook Demo specs, ordered by name as people number
367
+ * them ("2 …" before "10 …").
368
+ */
369
+ export function demoItemsFromSummaries(
370
+ summaries: readonly ISpecSummary[]
371
+ ): INotebookDemoItem[] {
372
+ // No id check: the client's listing already rejects any summary without a spec id.
373
+ return summaries
374
+ .map(summary => {
375
+ const title = summary.name.trim() || 'Untitled';
376
+ return summary.description
377
+ ? { id: summary.id, title, summary: summary.description }
378
+ : { id: summary.id, title };
379
+ })
380
+ .sort((a, b) =>
381
+ a.title.localeCompare(b.title, undefined, {
382
+ numeric: true,
383
+ sensitivity: 'base'
384
+ })
385
+ );
386
+ }
387
+
310
388
  /** Parse a spec store timestamp, which is UTC but usually has no zone designator. */
311
389
  export function parseTimestamp(value: string): Date | null {
312
390
  const zoned = /(Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`;
@@ -14,6 +14,9 @@ import type { Menu, Widget } from '@lumino/widgets';
14
14
  /** The widget id `@jupyterlab/filebrowser-extension:browser` gives the file browser. */
15
15
  export const FILE_BROWSER_ID = 'filebrowser';
16
16
 
17
+ /** The widget id of this extension's Oceanum.io panel, which takes the browser's place. */
18
+ export const OCEANUM_PANEL_ID = 'datamesh-connect';
19
+
17
20
  /**
18
21
  * File menu entries that manage local files. Save, Save All and Rename stay: saving is
19
22
  * routed to Oceanum, and renaming the local file is how a notebook gets its name.
@@ -28,23 +31,71 @@ export const LOCAL_FILE_COMMANDS: readonly string[] = [
28
31
  'docmanager:download'
29
32
  ];
30
33
 
34
+ /**
35
+ * File > New entries for files that are not notebooks. The spec store holds notebooks
36
+ * only, so such a file could never be saved anywhere the user can see.
37
+ */
38
+ export const NON_NOTEBOOK_NEW_COMMANDS: readonly string[] = [
39
+ 'fileeditor:create-new',
40
+ 'fileeditor:create-new-markdown-file'
41
+ ];
42
+
31
43
  /**
32
44
  * Take the file browser out of the sidebar; true if it was there. It is closed, not
33
45
  * disposed: other extensions hold it through IDefaultFileBrowser and still use it to
34
- * open paths.
46
+ * open paths. If it was the open panel, `instead` is opened in its place; a sidebar the
47
+ * user had collapsed stays collapsed.
35
48
  */
36
- export function hideFileBrowser(shell: {
37
- widgets(area: 'left'): Iterable<Widget>;
38
- }): boolean {
49
+ export function hideFileBrowser(
50
+ shell: {
51
+ widgets(area: 'left'): Iterable<Widget>;
52
+ activateById(id: string): void;
53
+ },
54
+ instead?: string
55
+ ): boolean {
39
56
  for (const widget of shell.widgets('left')) {
40
57
  if (widget.id === FILE_BROWSER_ID) {
58
+ const wasOpen = widget.isVisible;
41
59
  widget.close();
60
+ if (wasOpen && instead) {
61
+ shell.activateById(instead);
62
+ }
42
63
  return true;
43
64
  }
44
65
  }
45
66
  return false;
46
67
  }
47
68
 
69
+ /**
70
+ * Put a left-sidebar widget first, and keep it open if it was. True if it moved.
71
+ *
72
+ * A rank alone is not enough: the layout restorer replays the sidebar order each user
73
+ * last had, so a returning user would keep the panel wherever it used to be. Adding it
74
+ * again after the restore re-inserts it by rank, and the shell saves the new order.
75
+ */
76
+ export function moveToTop(
77
+ shell: {
78
+ widgets(area: 'left'): Iterable<Widget>;
79
+ add(widget: Widget, area: 'left', options: { rank: number }): void;
80
+ activateById(id: string): void;
81
+ },
82
+ id: string
83
+ ): boolean {
84
+ const widgets = Array.from(shell.widgets('left'));
85
+ const index = widgets.findIndex(widget => widget.id === id);
86
+ if (index <= 0) {
87
+ return false;
88
+ }
89
+ const widget = widgets[index];
90
+ // Adding hides the widget, so an open panel has to be opened again.
91
+ const wasOpen = widget.isVisible;
92
+ shell.add(widget, 'left', { rank: 0 });
93
+ if (wasOpen) {
94
+ shell.activateById(id);
95
+ }
96
+ return true;
97
+ }
98
+
48
99
  /**
49
100
  * Submenus of local files. "Open Recent" is built by the document manager rather than
50
101
  * declared in a schema, and its menu has no id, so it is known by the commands it holds.
@@ -16,7 +16,7 @@ import { IMainMenu } from '@jupyterlab/mainmenu';
16
16
  import type { INotebookContent } from '@jupyterlab/nbformat';
17
17
  import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
18
18
  import type { ReadonlyPartialJSONObject } from '@lumino/coreutils';
19
- import { Menu } from '@lumino/widgets';
19
+ import { Menu, type Widget } from '@lumino/widgets';
20
20
 
21
21
  import { startSignIn } from '../auth/startSignIn';
22
22
  import { IOceanumAuth } from '../auth/tokens';
@@ -29,7 +29,9 @@ import {
29
29
  ISpecRecord,
30
30
  METADATA_KEY,
31
31
  notebookFromRecord,
32
+ notebookFromUpload,
32
33
  notebookNamesFor,
34
+ NOTEBOOK_DEMO_TYPE,
33
35
  OCEANUM_DIR,
34
36
  partitionSummaries,
35
37
  QUERY_PARAM,
@@ -37,18 +39,25 @@ import {
37
39
  reportForShareFailures,
38
40
  sanitizeName,
39
41
  shareLink,
40
- uniqueNotebookPath
42
+ uniqueNotebookPath,
43
+ unlinkedNotebookFromRecord
41
44
  } from './notebook';
42
45
  import {
43
46
  hideFileBrowser,
47
+ moveToTop,
48
+ NON_NOTEBOOK_NEW_COMMANDS,
49
+ OCEANUM_PANEL_ID,
44
50
  pruneBeforeEachOpen,
45
51
  pruneFileMenu,
46
52
  routeSaves
47
53
  } from './oceanumOnly';
54
+ import { chooseNotebookFile } from './upload';
48
55
  import { NotebookListBody, ShareBody } from './widgets';
49
56
 
50
57
  export const CommandIDs = {
51
58
  open: 'oceanum-share:open',
59
+ openExample: 'oceanum-share:open-example',
60
+ upload: 'oceanum-share:upload',
52
61
  save: 'oceanum-share:save',
53
62
  share: 'oceanum-share:share',
54
63
  rename: 'oceanum-share:rename',
@@ -67,6 +76,8 @@ const UNAVAILABLE =
67
76
  interface IStore {
68
77
  specsUrl: string;
69
78
  client: SpecStoreClient;
79
+ /** The Notebook Demo specs, which are the Examples. */
80
+ demos: SpecStoreClient;
70
81
  }
71
82
 
72
83
  /**
@@ -102,6 +113,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
102
113
  client: new SpecStoreClient({
103
114
  specsUrl,
104
115
  getAccessToken: () => auth.getAccessToken()
116
+ }),
117
+ demos: new SpecStoreClient({
118
+ specsUrl,
119
+ getAccessToken: () => auth.getAccessToken(),
120
+ specType: NOTEBOOK_DEMO_TYPE
105
121
  })
106
122
  };
107
123
  const oceanumOnly =
@@ -317,6 +333,23 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
317
333
  return null;
318
334
  }
319
335
 
336
+ /** Write a notebook to `path` on the local drive and open it. */
337
+ async function writeAndOpen(
338
+ path: string,
339
+ content: INotebookContent
340
+ ): Promise<Widget> {
341
+ await docManager.services.contents.save(path, {
342
+ type: 'notebook',
343
+ format: 'json',
344
+ content
345
+ });
346
+ const widget = docManager.openOrReveal(path);
347
+ if (!widget) {
348
+ throw new Error(`Could not open ${path}.`);
349
+ }
350
+ return widget;
351
+ }
352
+
320
353
  /** Write a record to the local drive as `Oceanum/<name>.ipynb` and open it. */
321
354
  async function openRecord(s: IStore, record: ISpecRecord): Promise<void> {
322
355
  // Already open: bring it forward rather than writing a second copy.
@@ -333,14 +366,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
333
366
  const path =
334
367
  (await pathHoldingRecord(s, record, names)) ??
335
368
  uniqueNotebookPath(record.name, names);
336
- await docManager.services.contents.save(path, {
337
- type: 'notebook',
338
- format: 'json',
339
- content
340
- });
341
- if (!docManager.openOrReveal(path)) {
342
- throw new Error(`Could not open ${path}.`);
343
- }
369
+ await writeAndOpen(path, content);
344
370
  }
345
371
 
346
372
  async function openById(id: string): Promise<void> {
@@ -359,6 +385,84 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
359
385
  }
360
386
  }
361
387
 
388
+ // The copy each example was last opened into, so opening it again while that copy
389
+ // is still open brings it forward rather than writing another.
390
+ const exampleCopies = new Map<string, Widget>();
391
+ // Examples whose copy is still being written, so a second click in that time does
392
+ // not write a second copy.
393
+ const openingExamples = new Set<string>();
394
+
395
+ /**
396
+ * Open a copy of an example as a new file in the Oceanum folder, untrusted and
397
+ * linked to nothing. Not `openRecord`: that links the file to the example's record,
398
+ * which is Oceanum's and read-only to everyone else, so the first save would be
399
+ * refused. Unlinked, the first save creates a record of the user's own.
400
+ */
401
+ async function openExample(id: string, title: string): Promise<void> {
402
+ if (
403
+ !store ||
404
+ !(await ensureSignedIn(
405
+ 'Opening notebooks needs an Oceanum.io account.'
406
+ ))
407
+ ) {
408
+ return;
409
+ }
410
+ const copy = exampleCopies.get(id);
411
+ if (copy && !copy.isDisposed) {
412
+ app.shell.activateById(copy.id);
413
+ return;
414
+ }
415
+ if (openingExamples.has(id)) {
416
+ return;
417
+ }
418
+ openingExamples.add(id);
419
+ try {
420
+ const record = await store.demos.get(id);
421
+ const content = unlinkedNotebookFromRecord(record);
422
+ const path = uniqueNotebookPath(
423
+ title || record.name,
424
+ await oceanumFolderNames()
425
+ );
426
+ exampleCopies.set(id, await writeAndOpen(path, content));
427
+ } catch (error) {
428
+ await reportError('Open from Oceanum failed', error);
429
+ } finally {
430
+ openingExamples.delete(id);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Bring a notebook from the user's computer into the Oceanum folder and open it,
436
+ * untrusted and linked to nothing. It reaches Oceanum on its first save, like any
437
+ * new notebook; nothing is sent on upload.
438
+ */
439
+ async function uploadNotebook(): Promise<void> {
440
+ if (
441
+ !store ||
442
+ !(await ensureSignedIn(
443
+ 'Uploading notebooks needs an Oceanum.io account.'
444
+ ))
445
+ ) {
446
+ return;
447
+ }
448
+ try {
449
+ const file = await chooseNotebookFile();
450
+ if (!file) {
451
+ return;
452
+ }
453
+ const content = notebookFromUpload(await file.text());
454
+ const path = uniqueNotebookPath(file.name, await oceanumFolderNames());
455
+ await writeAndOpen(path, content);
456
+ // Outside Oceanum-only mode a plain Save stays local.
457
+ Notification.info(
458
+ `Uploaded "${file.name}". ${oceanumOnly ? 'Save it' : 'Use Save to Oceanum'} to store it on Oceanum.io.`,
459
+ { autoClose: 5000 }
460
+ );
461
+ } catch (error) {
462
+ await reportError('Upload failed', error);
463
+ }
464
+ }
465
+
362
466
  async function openFromOceanum(): Promise<void> {
363
467
  if (
364
468
  !store ||
@@ -959,6 +1063,26 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
959
1063
  await (id ? openById(id) : openFromOceanum());
960
1064
  }
961
1065
  });
1066
+ // Run by the Notebooks tab's Examples; not offered in menus or the palette.
1067
+ app.commands.addCommand(CommandIDs.openExample, {
1068
+ label: 'Open a copy',
1069
+ caption: 'Open a copy of an Oceanum example notebook',
1070
+ isEnabled: () => signedIn(),
1071
+ execute: async args => {
1072
+ if (isSpecId(args.id)) {
1073
+ await openExample(
1074
+ args.id,
1075
+ typeof args.title === 'string' ? args.title : ''
1076
+ );
1077
+ }
1078
+ }
1079
+ });
1080
+ app.commands.addCommand(CommandIDs.upload, {
1081
+ label: label('Upload Notebook…'),
1082
+ caption: 'Upload a notebook from this computer, to save on Oceanum.io',
1083
+ isEnabled: () => signedIn(),
1084
+ execute: () => uploadNotebook()
1085
+ });
962
1086
  app.commands.addCommand(CommandIDs.save, {
963
1087
  label: label('Save to Oceanum'),
964
1088
  caption: 'Save the notebook to Oceanum.io',
@@ -1013,6 +1137,7 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1013
1137
 
1014
1138
  const commands = [
1015
1139
  CommandIDs.open,
1140
+ CommandIDs.upload,
1016
1141
  CommandIDs.save,
1017
1142
  CommandIDs.share,
1018
1143
  CommandIDs.rename,
@@ -1075,7 +1200,9 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1075
1200
 
1076
1201
  if (oceanumOnly) {
1077
1202
  void app.restored.then(() => {
1078
- hideFileBrowser(app.shell);
1203
+ hideFileBrowser(app.shell, OCEANUM_PANEL_ID);
1204
+ // Oceanum is where notebooks live now, so its panel leads the sidebar.
1205
+ moveToTop(app.shell, OCEANUM_PANEL_ID);
1079
1206
  // IFileMenu is the narrow interface; the menu itself is a Lumino Menu, which
1080
1207
  // is what can drop items.
1081
1208
  const fileMenu = mainMenu?.fileMenu;
@@ -1083,6 +1210,11 @@ export const sharePlugin: JupyterFrontEndPlugin<void> = {
1083
1210
  pruneFileMenu(fileMenu);
1084
1211
  pruneBeforeEachOpen(fileMenu);
1085
1212
  }
1213
+ const newMenu = mainMenu?.fileMenu.newMenu;
1214
+ if (newMenu instanceof Menu) {
1215
+ pruneFileMenu(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
1216
+ pruneBeforeEachOpen(newMenu, NON_NOTEBOOK_NEW_COMMANDS);
1217
+ }
1086
1218
  routeSaves(tracker, pushQuietly);
1087
1219
  });
1088
1220
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ask the user for a notebook file on their computer; null if they cancel.
3
+ *
4
+ * Kept apart from the plugin because it is browser-only: the tests replace it.
5
+ */
6
+ export function chooseNotebookFile(): Promise<File | null> {
7
+ return new Promise(resolve => {
8
+ const input = document.createElement('input');
9
+ input.type = 'file';
10
+ input.accept = '.ipynb,application/x-ipynb+json';
11
+ input.hidden = true;
12
+ // Attached while the picker is open: some browsers (Safari) drop the `change`
13
+ // event of a detached input, or collect the input before it fires.
14
+ document.body.appendChild(input);
15
+ const settle = (file: File | null): void => {
16
+ input.remove();
17
+ resolve(file);
18
+ };
19
+ input.addEventListener('change', () => settle(input.files?.[0] ?? null), {
20
+ once: true
21
+ });
22
+ // Browsers fire `cancel` when the picker is dismissed; without it the promise
23
+ // would simply never settle, which is harmless.
24
+ input.addEventListener('cancel', () => settle(null), { once: true });
25
+ input.click();
26
+ });
27
+ }
package/style/index.css CHANGED
@@ -600,7 +600,8 @@
600
600
  list-style: none;
601
601
  }
602
602
 
603
- .oceanum-notebooks-item {
603
+ .oceanum-notebooks-item,
604
+ .oceanum-notebooks-example {
604
605
  display: block;
605
606
  width: 100%;
606
607
  padding: 4px 8px;
@@ -613,7 +614,8 @@
613
614
  cursor: pointer;
614
615
  }
615
616
 
616
- .oceanum-notebooks-item:hover {
617
+ .oceanum-notebooks-item:hover,
618
+ .oceanum-notebooks-example:hover {
617
619
  background: var(--jp-layout-color2);
618
620
  }
619
621
 
@@ -621,10 +623,100 @@
621
623
  cursor: default;
622
624
  }
623
625
 
626
+ .oceanum-notebooks-actions {
627
+ padding: 6px 8px 2px;
628
+ }
629
+
630
+ .oceanum-notebooks-upload {
631
+ padding: 3px 8px;
632
+ border: var(--jp-border-width) solid var(--jp-border-color1);
633
+ border-radius: var(--jp-border-radius);
634
+ background: var(--jp-layout-color1);
635
+ color: var(--jp-ui-font-color1);
636
+ font-family: var(--jp-ui-font-family);
637
+ font-size: var(--jp-ui-font-size1);
638
+ cursor: pointer;
639
+ }
640
+
641
+ .oceanum-notebooks-upload:hover {
642
+ background: var(--jp-layout-color2);
643
+ }
644
+
624
645
  .oceanum-notebooks-item-static:hover {
625
646
  background: transparent;
626
647
  }
627
648
 
649
+ /* The Examples heading's count and show/hide switch, kept together on the right. */
650
+ .oceanum-notebooks-heading-end {
651
+ display: flex;
652
+ align-items: center;
653
+ gap: 6px;
654
+ }
655
+
656
+ /* The Examples show/hide switch (a button with role="switch"). */
657
+ .oceanum-notebooks-switch {
658
+ position: relative;
659
+ flex: none;
660
+ width: 24px;
661
+ height: 14px;
662
+ padding: 0;
663
+ border: none;
664
+ border-radius: 7px;
665
+ background: var(--jp-layout-color3);
666
+ cursor: pointer;
667
+ }
668
+
669
+ .oceanum-notebooks-switch[aria-checked='true'] {
670
+ background: var(--jp-brand-color1);
671
+ }
672
+
673
+ .oceanum-notebooks-switch:focus-visible {
674
+ outline: 1px solid var(--jp-brand-color1);
675
+ outline-offset: 1px;
676
+ }
677
+
678
+ .oceanum-notebooks-switch-thumb {
679
+ position: absolute;
680
+ top: 2px;
681
+ left: 2px;
682
+ width: 10px;
683
+ height: 10px;
684
+ border-radius: 50%;
685
+ background: var(--jp-layout-color0);
686
+ transition: left 0.1s ease;
687
+ }
688
+
689
+ .oceanum-notebooks-switch[aria-checked='true'] .oceanum-notebooks-switch-thumb {
690
+ left: 12px;
691
+ }
692
+
693
+ /* Forced colours replace backgrounds, which would leave a switch drawn only with them
694
+ invisible: draw it with system colours instead. */
695
+ @media (forced-colors: active) {
696
+ .oceanum-notebooks-switch {
697
+ forced-color-adjust: none;
698
+ box-sizing: border-box;
699
+ border: 1px solid ButtonText;
700
+ background: Canvas;
701
+ }
702
+
703
+ .oceanum-notebooks-switch[aria-checked='true'] {
704
+ background: Highlight;
705
+ }
706
+
707
+ .oceanum-notebooks-switch-thumb {
708
+ top: 1px;
709
+ left: 1px;
710
+ background: ButtonText;
711
+ }
712
+
713
+ .oceanum-notebooks-switch[aria-checked='true']
714
+ .oceanum-notebooks-switch-thumb {
715
+ left: 11px;
716
+ background: HighlightText;
717
+ }
718
+ }
719
+
628
720
  .oceanum-notebooks-name {
629
721
  overflow: hidden;
630
722
  text-overflow: ellipsis;