@jupytergis/base 0.13.1 → 0.13.2

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.
@@ -16,10 +16,8 @@ export declare const newImageEntry = "jupytergis:newImageEntry";
16
16
  export declare const newVideoEntry = "jupytergis:newVideoEntry";
17
17
  export declare const newGeoTiffEntry = "jupytergis:newGeoTiffEntry";
18
18
  export declare const newGeoParquetEntry = "jupytergis:newGeoParquetEntry";
19
- export declare const renameLayer = "jupytergis:renameLayer";
20
- export declare const removeLayer = "jupytergis:removeLayer";
21
- export declare const renameGroup = "jupytergis:renameGroup";
22
- export declare const removeGroup = "jupytergis:removeGroup";
19
+ export declare const renameSelected = "jupytergis:renameSelected";
20
+ export declare const removeSelected = "jupytergis:removeSelected";
23
21
  export declare const moveLayersToGroup = "jupytergis:moveLayersToGroup";
24
22
  export declare const moveLayerToNewGroup = "jupytergis:moveLayerToNewGroup";
25
23
  export declare const renameSource = "jupytergis:renameSource";
@@ -25,10 +25,8 @@ export const newVideoEntry = 'jupytergis:newVideoEntry';
25
25
  export const newGeoTiffEntry = 'jupytergis:newGeoTiffEntry';
26
26
  export const newGeoParquetEntry = 'jupytergis:newGeoParquetEntry';
27
27
  // Layer and group actions
28
- export const renameLayer = 'jupytergis:renameLayer';
29
- export const removeLayer = 'jupytergis:removeLayer';
30
- export const renameGroup = 'jupytergis:renameGroup';
31
- export const removeGroup = 'jupytergis:removeGroup';
28
+ export const renameSelected = 'jupytergis:renameSelected';
29
+ export const removeSelected = 'jupytergis:removeSelected';
32
30
  export const moveLayersToGroup = 'jupytergis:moveLayersToGroup';
33
31
  export const moveLayerToNewGroup = 'jupytergis:moveLayerToNewGroup';
34
32
  // Source actions
@@ -361,41 +361,40 @@ export function addCommands(app, tracker, translator, formSchemaRegistry, layerB
361
361
  /**
362
362
  * LAYERS and LAYER GROUP actions.
363
363
  */
364
- commands.addCommand(CommandIDs.renameLayer, {
365
- label: trans.__('Rename Layer'),
366
- execute: async () => {
367
- var _a;
364
+ commands.addCommand(CommandIDs.renameSelected, {
365
+ label: trans.__('Rename'),
366
+ isEnabled: () => {
367
+ var _a, _b, _c;
368
368
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
369
- await Private.renameSelectedItem(model, 'layer');
369
+ const selected = (_c = (_b = model === null || model === void 0 ? void 0 : model.localState) === null || _b === void 0 ? void 0 : _b.selected) === null || _c === void 0 ? void 0 : _c.value;
370
+ return !!selected && Object.keys(selected).length === 1;
370
371
  },
371
- });
372
- commands.addCommand(CommandIDs.removeLayer, {
373
- label: trans.__('Remove Layer'),
374
- execute: () => {
375
- var _a;
372
+ execute: async () => {
373
+ var _a, _b, _c;
376
374
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
377
- Private.removeSelectedItems(model, 'layer', selection => {
378
- model === null || model === void 0 ? void 0 : model.removeLayer(selection);
379
- });
380
- commands.notifyCommandChanged(CommandIDs.toggleStoryPresentationMode);
375
+ const selected = (_c = (_b = model === null || model === void 0 ? void 0 : model.localState) === null || _b === void 0 ? void 0 : _b.selected) === null || _c === void 0 ? void 0 : _c.value;
376
+ if (!model || !selected) {
377
+ return;
378
+ }
379
+ await Private.renameSelectedItem(model);
381
380
  },
382
381
  });
383
- commands.addCommand(CommandIDs.renameGroup, {
384
- label: trans.__('Rename Group'),
385
- execute: async () => {
386
- var _a;
382
+ commands.addCommand(CommandIDs.removeSelected, {
383
+ label: trans.__('Remove'),
384
+ isEnabled: () => {
385
+ var _a, _b, _c;
387
386
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
388
- await Private.renameSelectedItem(model, 'group');
387
+ const selected = (_c = (_b = model === null || model === void 0 ? void 0 : model.localState) === null || _b === void 0 ? void 0 : _b.selected) === null || _c === void 0 ? void 0 : _c.value;
388
+ return !!selected && Object.keys(selected).length > 0;
389
389
  },
390
- });
391
- commands.addCommand(CommandIDs.removeGroup, {
392
- label: trans.__('Remove Group'),
393
390
  execute: async () => {
394
- var _a;
391
+ var _a, _b, _c;
395
392
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
396
- Private.removeSelectedItems(model, 'group', selection => {
397
- model === null || model === void 0 ? void 0 : model.removeLayerGroup(selection);
398
- });
393
+ const selected = (_c = (_b = model === null || model === void 0 ? void 0 : model.localState) === null || _b === void 0 ? void 0 : _b.selected) === null || _c === void 0 ? void 0 : _c.value;
394
+ if (!model || !selected) {
395
+ return;
396
+ }
397
+ await Private.removeSelectedItems(model);
399
398
  },
400
399
  });
401
400
  commands.addCommand(CommandIDs.moveLayersToGroup, {
@@ -473,7 +472,7 @@ export function addCommands(app, tracker, translator, formSchemaRegistry, layerB
473
472
  execute: async () => {
474
473
  var _a;
475
474
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
476
- await Private.renameSelectedItem(model, 'source');
475
+ await Private.renameSelectedItem(model);
477
476
  },
478
477
  });
479
478
  commands.addCommand(CommandIDs.removeSource, {
@@ -481,15 +480,7 @@ export function addCommands(app, tracker, translator, formSchemaRegistry, layerB
481
480
  execute: () => {
482
481
  var _a;
483
482
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
484
- Private.removeSelectedItems(model, 'source', selection => {
485
- var _a;
486
- if (!((_a = model === null || model === void 0 ? void 0 : model.getLayersBySource(selection).length) !== null && _a !== void 0 ? _a : true)) {
487
- model === null || model === void 0 ? void 0 : model.sharedModel.removeSource(selection);
488
- }
489
- else {
490
- showErrorMessage('Remove source error', 'The source is used by a layer.');
491
- }
492
- });
483
+ Private.removeSelectedSources(model);
493
484
  },
494
485
  });
495
486
  // Console commands
@@ -934,42 +925,60 @@ var Private;
934
925
  };
935
926
  }
936
927
  Private.createEntry = createEntry;
937
- function removeSelectedItems(model, itemTypeToRemove, removeFunction) {
928
+ function removeSelectedItems(model) {
938
929
  var _a, _b;
939
930
  const selected = (_b = (_a = model === null || model === void 0 ? void 0 : model.localState) === null || _a === void 0 ? void 0 : _a.selected) === null || _b === void 0 ? void 0 : _b.value;
940
- if (!selected) {
931
+ if (!selected || !model) {
941
932
  console.error('Failed to remove selected item -- nothing selected');
942
933
  return;
943
934
  }
944
- for (const selection in selected) {
945
- if (selected[selection].type === itemTypeToRemove) {
946
- removeFunction(selection);
935
+ for (const id of Object.keys(selected)) {
936
+ const item = selected[id];
937
+ switch (item.type) {
938
+ case 'layer':
939
+ model.removeLayer(id);
940
+ break;
941
+ case 'group':
942
+ model.removeLayerGroup(id);
943
+ break;
947
944
  }
948
945
  }
949
946
  }
950
947
  Private.removeSelectedItems = removeSelectedItems;
951
- async function renameSelectedItem(model, itemType) {
952
- var _a;
953
- const selectedItems = (_a = model === null || model === void 0 ? void 0 : model.localState) === null || _a === void 0 ? void 0 : _a.selected.value;
948
+ async function renameSelectedItem(model) {
949
+ var _a, _b;
950
+ const selectedItems = (_b = (_a = model === null || model === void 0 ? void 0 : model.localState) === null || _a === void 0 ? void 0 : _a.selected) === null || _b === void 0 ? void 0 : _b.value;
954
951
  if (!selectedItems || !model) {
955
- console.error(`No ${itemType} selected`);
952
+ console.error('No item selected');
956
953
  return;
957
954
  }
958
- let itemId = '';
959
- // If more then one item is selected, only rename the first
960
- for (const id in selectedItems) {
961
- if (selectedItems[id].type === itemType) {
962
- itemId = id;
963
- break;
964
- }
955
+ const ids = Object.keys(selectedItems);
956
+ if (ids.length === 0) {
957
+ return;
965
958
  }
966
- if (!itemId) {
959
+ const itemId = ids[0];
960
+ const item = selectedItems[itemId];
961
+ if (!item.type) {
967
962
  return;
968
963
  }
969
- // Set editing state - component will show inline input
970
- model.setEditingItem(itemType, itemId);
964
+ model.setEditingItem(item.type, itemId);
971
965
  }
972
966
  Private.renameSelectedItem = renameSelectedItem;
967
+ function removeSelectedSources(model) {
968
+ var _a, _b;
969
+ const selected = (_b = (_a = model === null || model === void 0 ? void 0 : model.localState) === null || _a === void 0 ? void 0 : _a.selected) === null || _b === void 0 ? void 0 : _b.value;
970
+ if (!selected || !model) {
971
+ return;
972
+ }
973
+ for (const id of Object.keys(selected)) {
974
+ if (model.getLayersBySource(id).length > 0) {
975
+ showErrorMessage('Remove source error', 'The source is used by a layer.');
976
+ continue;
977
+ }
978
+ model.sharedModel.removeSource(id);
979
+ }
980
+ }
981
+ Private.removeSelectedSources = removeSelectedSources;
973
982
  function executeConsole(tracker) {
974
983
  const current = tracker.currentWidget;
975
984
  if (!current || !(current instanceof JupyterGISDocumentWidget)) {
@@ -25,7 +25,7 @@ const ColorRampSelector = ({ selectedRamp, setSelected, }) => {
25
25
  if (colorMaps.length > 0) {
26
26
  updateCanvas(selectedRamp);
27
27
  }
28
- }, [selectedRamp]);
28
+ }, [selectedRamp, colorMaps]);
29
29
  const toggleDropdown = () => {
30
30
  setIsOpen(!isOpen);
31
31
  };
@@ -30,24 +30,14 @@
30
30
  "selector": ".data-jgis-keybinding .jp-gis-source"
31
31
  },
32
32
  {
33
- "command": "jupytergis:removeLayer",
33
+ "command": "jupytergis:removeSelected",
34
34
  "keys": ["Delete"],
35
- "selector": ".data-jgis-keybinding .jp-gis-layerItem"
36
- },
37
- {
38
- "command": "jupytergis:renameLayer",
39
- "keys": ["F2"],
40
- "selector": ".jp-gis-layerItem"
41
- },
42
- {
43
- "command": "jupytergis:removeGroup",
44
- "keys": ["Delete"],
45
- "selector": ".data-jgis-keybinding .jp-gis-layerGroupHeader"
35
+ "selector": ".data-jgis-keybinding"
46
36
  },
47
37
  {
48
- "command": "jupytergis:renameGroup",
38
+ "command": "jupytergis:renameSelected",
49
39
  "keys": ["F2"],
50
- "selector": ".jp-gis-layerGroupHeader"
40
+ "selector": ".data-jgis-keybinding"
51
41
  },
52
42
  {
53
43
  "command": "jupytergis:executeConsole",
@@ -225,6 +225,8 @@ export declare class MainView extends React.Component<IProps, IStates> {
225
225
  private _featurePropertyCache;
226
226
  private _isSpectaPresentationInitialized;
227
227
  private _storyScrollHandler;
228
+ private _clearStoryScrollGuard;
229
+ private _pendingStoryScrollRafId;
228
230
  }
229
231
  /** Thin wrapper that injects isMobile from useMediaQuery so MainView can use it in JSX. */
230
232
  declare function MainViewWithMediaQuery(props: IProps): React.JSX.Element;
@@ -447,72 +447,75 @@ export class MainView extends React.Component {
447
447
  });
448
448
  };
449
449
  this._setupStoryScrollListener = () => {
450
- const segmentNavigationThrottle = 750; // Minimum time between segment changes (ms)
451
- const SCROLL_EDGE_THRESHOLD = 0; // Pixels from top/bottom to trigger segment change
452
- // Create throttled functions that call the current panel handle dynamically
453
- const throttledHandleNext = throttle(() => {
454
- const panelHandle = this.storyViewerPanelRef.current;
455
- panelHandle === null || panelHandle === void 0 ? void 0 : panelHandle.handleNext();
456
- }, segmentNavigationThrottle);
457
- const throttledHandlePrev = throttle(() => {
458
- const panelHandle = this.storyViewerPanelRef.current;
459
- panelHandle === null || panelHandle === void 0 ? void 0 : panelHandle.handlePrev();
460
- }, segmentNavigationThrottle);
461
- const handleScroll = (e) => {
450
+ var _a, _b;
451
+ // Guard: block wheel-driven segment change until transition has ended
452
+ let segmentChangeInProgress = false;
453
+ const clearGuard = () => {
454
+ segmentChangeInProgress = false;
455
+ };
456
+ this._clearStoryScrollGuard = clearGuard;
457
+ let accumulatedDeltaY = 0;
458
+ let scrollContainer = (_b = (_a = this.storyViewerPanelRef.current) === null || _a === void 0 ? void 0 : _a.getScrollContainer()) !== null && _b !== void 0 ? _b : null;
459
+ const processStoryScrollFrame = () => {
460
+ this._pendingStoryScrollRafId = null;
462
461
  const currentPanelHandle = this.storyViewerPanelRef.current;
463
- if (!currentPanelHandle || !currentPanelHandle.canNavigate) {
462
+ if (!currentPanelHandle || !scrollContainer) {
463
+ accumulatedDeltaY = 0;
464
464
  return;
465
465
  }
466
- const wheelEvent = e;
467
- const target = wheelEvent.target;
468
- // Find the story viewer panel
469
- const storyViewerPanel = document.querySelector('.jgis-story-viewer-panel');
470
- // If no panel found, change segments normally
471
- if (!storyViewerPanel) {
472
- wheelEvent.preventDefault();
473
- wheelEvent.deltaY > 0 ? throttledHandleNext() : throttledHandlePrev();
466
+ const deltaY = accumulatedDeltaY;
467
+ accumulatedDeltaY = 0;
468
+ const isScrollingUp = deltaY < 0;
469
+ const isScrollingDown = deltaY > 0;
470
+ const isAtTop = currentPanelHandle.getAtTop();
471
+ const isAtBottom = currentPanelHandle.getAtBottom();
472
+ const hasOverflow = !(isAtTop && isAtBottom);
473
+ const canGoInDirection = (isScrollingDown && currentPanelHandle.hasNext) ||
474
+ (isScrollingUp && currentPanelHandle.hasPrev);
475
+ const atEdge = (isScrollingDown && isAtBottom) || (isScrollingUp && isAtTop);
476
+ const wantSegmentChange = canGoInDirection && (!hasOverflow || atEdge);
477
+ if (wantSegmentChange) {
478
+ if (segmentChangeInProgress) {
479
+ return;
480
+ }
481
+ segmentChangeInProgress = true;
482
+ isScrollingDown
483
+ ? currentPanelHandle.handleNext()
484
+ : currentPanelHandle.handlePrev();
474
485
  return;
475
486
  }
476
- const hasOverflow = storyViewerPanel.scrollHeight > storyViewerPanel.clientHeight;
477
- // If panel has no overflow, change segments normally
478
- if (!hasOverflow) {
479
- wheelEvent.preventDefault();
480
- wheelEvent.deltaY > 0 ? throttledHandleNext() : throttledHandlePrev();
481
- return;
487
+ scrollContainer.scrollBy({ top: deltaY });
488
+ };
489
+ const handleScroll = (event) => {
490
+ var _a, _b;
491
+ event.preventDefault();
492
+ if (!scrollContainer || !document.contains(scrollContainer)) {
493
+ scrollContainer =
494
+ (_b = (_a = this.storyViewerPanelRef.current) === null || _a === void 0 ? void 0 : _a.getScrollContainer()) !== null && _b !== void 0 ? _b : null;
482
495
  }
483
- // Panel has overflow - handle scroll forwarding and edge detection
484
- const scrollTop = storyViewerPanel.scrollTop;
485
- const scrollHeight = storyViewerPanel.scrollHeight;
486
- const clientHeight = storyViewerPanel.clientHeight;
487
- const isAtBottom = scrollTop + clientHeight >= scrollHeight - SCROLL_EDGE_THRESHOLD;
488
- const isAtTop = scrollTop <= SCROLL_EDGE_THRESHOLD;
489
- const isScrollingDown = wheelEvent.deltaY > 0;
490
- const isScrollingUp = wheelEvent.deltaY < 0;
491
- // At edges: change segments
492
- if ((isScrollingDown && isAtBottom) || (isScrollingUp && isAtTop)) {
493
- wheelEvent.preventDefault();
494
- isScrollingDown ? throttledHandleNext() : throttledHandlePrev();
496
+ if (!scrollContainer) {
495
497
  return;
496
498
  }
497
- // If scrolling inside the panel, let it scroll naturally
498
- if (target.closest('.jgis-story-viewer-panel')) {
499
- return;
499
+ // One physical scroll tick often fires ~4 wheel events (sometimes across
500
+ // frames on slow hardware). We accumulate deltaY and run flush once per
501
+ // frame via rAF—the frame boundary batches events without adding delay.
502
+ // So one scroll means one segment/scroll decision.
503
+ accumulatedDeltaY += event.deltaY;
504
+ if (this._pendingStoryScrollRafId === null) {
505
+ this._pendingStoryScrollRafId = requestAnimationFrame(processStoryScrollFrame);
500
506
  }
501
- // Scrolling outside the panel: forward scroll to panel (no throttling for smooth scrolling)
502
- wheelEvent.preventDefault();
503
- const newScrollTop = Math.max(0, Math.min(scrollHeight - clientHeight, scrollTop + wheelEvent.deltaY));
504
- storyViewerPanel.scrollTop = newScrollTop;
505
507
  };
506
508
  this._storyScrollHandler = handleScroll;
507
- // Attach wheel event listener to the main container
508
- const containerElement = document.querySelector('.jGIS-Mainview-Container');
509
- if (containerElement) {
510
- containerElement.addEventListener('wheel', handleScroll, {
511
- passive: false,
512
- });
509
+ const container = document.querySelector('.jGIS-Mainview-Container');
510
+ if (container) {
511
+ container.addEventListener('wheel', handleScroll, { passive: false });
513
512
  }
514
513
  };
515
514
  this._cleanupStoryScrollListener = () => {
515
+ if (this._pendingStoryScrollRafId !== null) {
516
+ cancelAnimationFrame(this._pendingStoryScrollRafId);
517
+ this._pendingStoryScrollRafId = null;
518
+ }
516
519
  if (this._storyScrollHandler) {
517
520
  const containerElement = document.querySelector('.jGIS-Mainview-Container');
518
521
  if (containerElement) {
@@ -596,6 +599,7 @@ export class MainView extends React.Component {
596
599
  this._featurePropertyCache = new Map();
597
600
  this._isSpectaPresentationInitialized = false;
598
601
  this._storyScrollHandler = null;
602
+ this._pendingStoryScrollRafId = null;
599
603
  this._state = props.state;
600
604
  this._formSchemaRegistry = props.formSchemaRegistry;
601
605
  this._annotationModel = props.annotationModel;
@@ -2094,7 +2098,7 @@ export class MainView extends React.Component {
2094
2098
  this._state && (React.createElement(LeftPanel, { model: this._model, commands: this._mainViewModel.commands, state: this._state, settings: this.state.jgisSettings })),
2095
2099
  this._formSchemaRegistry && this._annotationModel && (React.createElement(RightPanel, { model: this._model, commands: this._mainViewModel.commands, formSchemaRegistry: this._formSchemaRegistry, annotationModel: this._annotationModel, addLayer: this.addLayer.bind(this), removeLayer: this.removeLayer.bind(this), settings: this.state.jgisSettings })))) : this.props.isMobile ? (React.createElement(MobileSpectaPanel, { model: this._model })) : (React.createElement("div", { className: "jgis-specta-right-panel-container-mod jgis-right-panel-container" },
2096
2100
  React.createElement("div", { ref: this.spectaContainerRef, className: "jgis-specta-story-panel-container" },
2097
- React.createElement(StoryViewerPanel, { ref: this.storyViewerPanelRef, model: this._model, isSpecta: this.state.isSpectaPresentation, className: "jgis-story-viewer-panel-specta-mod" }))))),
2101
+ React.createElement(StoryViewerPanel, { ref: this.storyViewerPanelRef, model: this._model, isSpecta: this.state.isSpectaPresentation, className: "jgis-story-viewer-panel-specta-mod", onSegmentTransitionEnd: () => this._clearStoryScrollGuard() }))))),
2098
2102
  React.createElement("div", { ref: this.controlsToolbarRef, className: "jgis-controls-toolbar" }))),
2099
2103
  !this.state.isSpectaPresentation && (React.createElement(StatusBar, { jgisModel: this._model, loading: this.state.loadingLayer, projection: this.state.viewProjection, scale: this.state.scale })))));
2100
2104
  }
@@ -345,6 +345,11 @@ const LayerComponent = props => {
345
345
  const moveToExtent = () => {
346
346
  gisModel === null || gisModel === void 0 ? void 0 : gisModel.centerOnPosition(layerId);
347
347
  };
348
+ const handleDoubleClick = (e) => {
349
+ e.preventDefault();
350
+ e.stopPropagation();
351
+ moveToExtent();
352
+ };
348
353
  const getSlideNumber = () => {
349
354
  if (!gisModel) {
350
355
  return;
@@ -373,7 +378,7 @@ const LayerComponent = props => {
373
378
  padding: '2px 4px',
374
379
  fontSize: 'inherit',
375
380
  fontFamily: 'inherit',
376
- }, autoFocus: true })) : (React.createElement("span", { id: id, className: LAYER_TEXT_CLASS, tabIndex: -2 }, name)),
381
+ }, autoFocus: true })) : (React.createElement("span", { id: id, className: LAYER_TEXT_CLASS, tabIndex: -2, onDoubleClick: handleDoubleClick, title: "Double-click to zoom to layer" }, name)),
377
382
  React.createElement(Button, { title: 'Move map to the extent of the layer', onClick: moveToExtent, minimal: true },
378
383
  React.createElement(LabIcon.resolveReact, { icon: targetWithCenterIcon, className: LAYER_ICON_CLASS, tag: "span" }))),
379
384
  expanded && gisModel && hasSupportedSymbology && (React.createElement("div", { style: { marginTop: 6, width: '100%' } },
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import Draggable from 'react-draggable';
2
3
  import { CommandIDs } from '../constants';
3
4
  import { LayersBodyComponent } from './components/layers';
4
5
  import FilterComponent from './filter-panel/Filter';
@@ -123,22 +124,23 @@ export const LeftPanel = (props) => {
123
124
  props.settings.filtersDisabled &&
124
125
  props.settings.storyMapsDisabled;
125
126
  const leftPanelVisible = !props.settings.leftPanelDisabled && !allLeftTabsDisabled;
126
- return (React.createElement("div", { className: "jgis-left-panel-container", style: { display: leftPanelVisible ? 'block' : 'none' } },
127
- React.createElement(PanelTabs, { curTab: curTab, className: "jgis-panel-tabs" },
128
- React.createElement(TabsList, null, tabInfo.map(tab => (React.createElement(TabsTrigger, { className: "jGIS-layer-browser-category", key: tab.name, value: tab.name, onClick: () => {
129
- if (curTab !== tab.name) {
130
- setCurTab(tab.name);
131
- }
132
- else {
133
- setCurTab('');
134
- }
135
- } }, tab.title)))),
136
- !props.settings.layersDisabled && (React.createElement(TabsContent, { value: "layers", className: "jgis-panel-tab-content jp-gis-layerPanel" },
137
- React.createElement(LayersBodyComponent, { model: props.model, commands: props.commands, state: props.state, layerTree: filteredLayerTree }))),
138
- !props.settings.stacBrowserDisabled && (React.createElement(TabsContent, { value: "stac", className: "jgis-panel-tab-content jgis-panel-tab-content-stac-panel" },
139
- React.createElement(StacPanel, { model: props.model }))),
140
- !props.settings.filtersDisabled && (React.createElement(TabsContent, { value: "filters", className: "jgis-panel-tab-content" },
141
- React.createElement(FilterComponent, { model: props.model }))),
142
- !props.settings.storyMapsDisabled && (React.createElement(TabsContent, { value: "segments", className: "jgis-panel-tab-content" },
143
- React.createElement(LayersBodyComponent, { model: props.model, commands: props.commands, state: props.state, layerTree: storySegmentLayerTree }))))));
127
+ return (React.createElement(Draggable, { handle: ".jgis-tabs-list", cancel: ".jgis-tabs-trigger", bounds: ".jGIS-Mainview-Container" },
128
+ React.createElement("div", { className: "jgis-left-panel-container", style: { display: leftPanelVisible ? 'block' : 'none' } },
129
+ React.createElement(PanelTabs, { curTab: curTab, className: "jgis-panel-tabs" },
130
+ React.createElement(TabsList, null, tabInfo.map(tab => (React.createElement(TabsTrigger, { className: "jGIS-layer-browser-category", key: tab.name, value: tab.name, onClick: () => {
131
+ if (curTab !== tab.name) {
132
+ setCurTab(tab.name);
133
+ }
134
+ else {
135
+ setCurTab('');
136
+ }
137
+ } }, tab.title)))),
138
+ !props.settings.layersDisabled && (React.createElement(TabsContent, { value: "layers", className: "jgis-panel-tab-content jp-gis-layerPanel" },
139
+ React.createElement(LayersBodyComponent, { model: props.model, commands: props.commands, state: props.state, layerTree: filteredLayerTree }))),
140
+ !props.settings.stacBrowserDisabled && (React.createElement(TabsContent, { value: "stac", className: "jgis-panel-tab-content jgis-panel-tab-content-stac-panel" },
141
+ React.createElement(StacPanel, { model: props.model }))),
142
+ !props.settings.filtersDisabled && (React.createElement(TabsContent, { value: "filters", className: "jgis-panel-tab-content" },
143
+ React.createElement(FilterComponent, { model: props.model }))),
144
+ !props.settings.storyMapsDisabled && (React.createElement(TabsContent, { value: "segments", className: "jgis-panel-tab-content" },
145
+ React.createElement(LayersBodyComponent, { model: props.model, commands: props.commands, state: props.state, layerTree: storySegmentLayerTree })))))));
144
146
  };
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import Draggable from 'react-draggable';
2
3
  import { AnnotationsPanel } from './annotationPanel';
3
4
  import { IdentifyPanelComponent } from './identify-panel/IdentifyPanel';
4
5
  import { ObjectPropertiesReact } from './objectproperties';
@@ -74,23 +75,24 @@ export const RightPanel = props => {
74
75
  const toggleEditor = () => {
75
76
  setEditorMode(!editorMode);
76
77
  };
77
- return (React.createElement("div", { className: "jgis-right-panel-container", style: { display: rightPanelVisible ? 'block' : 'none' } },
78
- React.createElement(PanelTabs, { className: "jgis-panel-tabs", curTab: curTab },
79
- React.createElement(TabsList, null, tabInfo.map(tab => (React.createElement(TabsTrigger, { className: "jGIS-layer-browser-category", key: `${tab.name}-${tab.title}`, value: tab.name, onClick: () => {
80
- if (curTab !== tab.name) {
81
- setCurTab(tab.name);
82
- }
83
- else {
84
- setCurTab('');
85
- }
86
- } }, tab.title)))),
87
- !props.settings.objectPropertiesDisabled && (React.createElement(TabsContent, { value: "objectProperties", className: "jgis-panel-tab-content" },
88
- React.createElement(ObjectPropertiesReact, { setSelectedObject: setSelectedObjectProperties, selectedObject: selectedObjectProperties, formSchemaRegistry: props.formSchemaRegistry, model: props.model }))),
89
- !props.settings.storyMapsDisabled && (React.createElement(TabsContent, { value: "storyPanel", className: "jgis-panel-tab-content", style: { paddingTop: 0 } },
90
- !storyMapPresentationMode && (React.createElement(PreviewModeSwitch, { checked: !editorMode, onCheckedChange: toggleEditor })),
91
- showEditor ? (React.createElement(StoryEditorPanel, { model: props.model, commands: props.commands })) : (React.createElement(StoryViewerPanel, { model: props.model, isSpecta: false, addLayer: props.addLayer, removeLayer: props.removeLayer })))),
92
- !props.settings.annotationsDisabled && (React.createElement(TabsContent, { value: "annotations", className: "jgis-panel-tab-content" },
93
- React.createElement(AnnotationsPanel, { annotationModel: props.annotationModel, jgisModel: props.model }))),
94
- !props.settings.identifyDisabled && (React.createElement(TabsContent, { value: "identifyPanel", className: "jgis-panel-tab-content" },
95
- React.createElement(IdentifyPanelComponent, { model: props.model }))))));
78
+ return (React.createElement(Draggable, { handle: ".jgis-tabs-list", cancel: ".jgis-tabs-trigger", bounds: ".jGIS-Mainview-Container" },
79
+ React.createElement("div", { className: "jgis-right-panel-container", style: { display: rightPanelVisible ? 'block' : 'none' } },
80
+ React.createElement(PanelTabs, { className: "jgis-panel-tabs", curTab: curTab },
81
+ React.createElement(TabsList, null, tabInfo.map(tab => (React.createElement(TabsTrigger, { className: "jGIS-layer-browser-category", key: `${tab.name}-${tab.title}`, value: tab.name, onClick: () => {
82
+ if (curTab !== tab.name) {
83
+ setCurTab(tab.name);
84
+ }
85
+ else {
86
+ setCurTab('');
87
+ }
88
+ } }, tab.title)))),
89
+ !props.settings.objectPropertiesDisabled && (React.createElement(TabsContent, { value: "objectProperties", className: "jgis-panel-tab-content" },
90
+ React.createElement(ObjectPropertiesReact, { setSelectedObject: setSelectedObjectProperties, selectedObject: selectedObjectProperties, formSchemaRegistry: props.formSchemaRegistry, model: props.model }))),
91
+ !props.settings.storyMapsDisabled && (React.createElement(TabsContent, { value: "storyPanel", className: "jgis-panel-tab-content", style: { paddingTop: 0 } },
92
+ !storyMapPresentationMode && (React.createElement(PreviewModeSwitch, { checked: !editorMode, onCheckedChange: toggleEditor })),
93
+ showEditor ? (React.createElement(StoryEditorPanel, { model: props.model, commands: props.commands })) : (React.createElement(StoryViewerPanel, { model: props.model, isSpecta: false, addLayer: props.addLayer, removeLayer: props.removeLayer })))),
94
+ !props.settings.annotationsDisabled && (React.createElement(TabsContent, { value: "annotations", className: "jgis-panel-tab-content" },
95
+ React.createElement(AnnotationsPanel, { annotationModel: props.annotationModel, jgisModel: props.model }))),
96
+ !props.settings.identifyDisabled && (React.createElement(TabsContent, { value: "identifyPanel", className: "jgis-panel-tab-content" },
97
+ React.createElement(IdentifyPanelComponent, { model: props.model })))))));
96
98
  };
@@ -7,11 +7,19 @@ interface IStoryViewerPanelProps {
7
7
  className?: string;
8
8
  addLayer?: (id: string, layer: IJGISLayer, index: number) => Promise<void>;
9
9
  removeLayer?: (id: string) => void;
10
+ /** Called when the segment transition animation has finished (e.g. for scroll-guard cleanup). */
11
+ onSegmentTransitionEnd?: () => void;
10
12
  }
11
13
  export interface IStoryViewerPanelHandle {
12
14
  handlePrev: () => void;
13
15
  handleNext: () => void;
14
- canNavigate: boolean;
16
+ spectaMode: boolean;
17
+ hasPrev: boolean;
18
+ hasNext: boolean;
19
+ getAtTop: () => boolean;
20
+ getAtBottom: () => boolean;
21
+ /** The scrollable panel DOM element (same instance for all segments). */
22
+ getScrollContainer: () => HTMLDivElement | null;
15
23
  }
16
24
  /**
17
25
  * Where the story nav bar should be rendered in the viewer layout.
@@ -18,12 +18,17 @@ function getStoryNavPlacement(isSpecta, hasImage, storyType, isMobile) {
18
18
  }
19
19
  return hasImage ? 'over-image' : 'below-title';
20
20
  }
21
- const StoryViewerPanel = forwardRef(({ model, isSpecta, isMobile = false, className, addLayer, removeLayer }, ref) => {
21
+ const StoryViewerPanel = forwardRef(({ model, isSpecta, isMobile = false, className, addLayer, removeLayer, onSegmentTransitionEnd, }, ref) => {
22
22
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
23
23
  const [currentIndexDisplayed, setCurrentIndexDisplayed] = useState(() => model.getCurrentSegmentIndex());
24
24
  const [storyData, setStoryData] = useState((_a = model.getSelectedStory().story) !== null && _a !== void 0 ? _a : null);
25
25
  const [imageLoaded, setImageLoaded] = useState(false);
26
26
  const panelRef = useRef(null);
27
+ const segmentContainerRef = useRef(null);
28
+ const topSentinelRef = useRef(null);
29
+ const bottomSentinelRef = useRef(null);
30
+ const atTopRef = useRef(false);
31
+ const atBottomRef = useRef(false);
27
32
  const setIndex = useCallback((index) => {
28
33
  model.setCurrentSegmentIndex(index);
29
34
  setCurrentIndexDisplayed(index);
@@ -283,20 +288,63 @@ const StoryViewerPanel = forwardRef(({ model, isSpecta, isMobile = false, classN
283
288
  hasPrev,
284
289
  hasNext,
285
290
  };
291
+ // IntersectionObserver for at-top/at-bottom (avoids layout reads in scroll path)
292
+ useEffect(() => {
293
+ const root = panelRef.current;
294
+ const topEl = topSentinelRef.current;
295
+ const bottomEl = bottomSentinelRef.current;
296
+ if (!root || !topEl || !bottomEl) {
297
+ return;
298
+ }
299
+ const observer = new IntersectionObserver((entries) => {
300
+ for (const entry of entries) {
301
+ if (entry.target === topEl) {
302
+ atTopRef.current = entry.isIntersecting;
303
+ }
304
+ else if (entry.target === bottomEl) {
305
+ atBottomRef.current = entry.isIntersecting;
306
+ }
307
+ }
308
+ }, { root, threshold: 0, rootMargin: '0px' });
309
+ observer.observe(topEl);
310
+ observer.observe(bottomEl);
311
+ return () => observer.disconnect();
312
+ }, [currentIndexDisplayed]);
286
313
  // Expose methods via ref for parent component to use
287
314
  useImperativeHandle(ref, () => ({
288
315
  handlePrev,
289
316
  handleNext,
290
- canNavigate: isSpecta,
291
- }), [handlePrev, handleNext, storyData, isSpecta]);
317
+ spectaMode: isSpecta,
318
+ hasPrev,
319
+ hasNext,
320
+ getAtTop: () => atTopRef.current,
321
+ getAtBottom: () => atBottomRef.current,
322
+ getScrollContainer: () => panelRef.current,
323
+ }), [handlePrev, handleNext, storyData, isSpecta, hasPrev, hasNext]);
292
324
  const hasImage = !!(((_d = activeSlide === null || activeSlide === void 0 ? void 0 : activeSlide.content) === null || _d === void 0 ? void 0 : _d.image) && imageLoaded);
293
325
  const storyType = (_e = storyData.storyType) !== null && _e !== void 0 ? _e : 'guided';
294
326
  const navPlacement = getStoryNavPlacement(isSpecta, hasImage, storyType, isMobile);
295
327
  const navSlot = navPlacement !== null ? (React.createElement(StoryNavBar, Object.assign({ placement: navPlacement }, storyNavBarProps))) : null;
296
328
  // Get transition time from current segment, default to 0.3s
297
329
  const transitionTime = (_g = (_f = activeSlide === null || activeSlide === void 0 ? void 0 : activeSlide.transition) === null || _f === void 0 ? void 0 : _f.time) !== null && _g !== void 0 ? _g : 0.3;
330
+ // Notify parent when segment transition animation ends (e.g. for scroll-guard cleanup)
331
+ useEffect(() => {
332
+ const el = segmentContainerRef.current;
333
+ if (!el || !onSegmentTransitionEnd) {
334
+ return;
335
+ }
336
+ const handleAnimationEnd = (e) => {
337
+ if (e.animationName === 'fadeIn') {
338
+ el.removeEventListener('animationend', handleAnimationEnd);
339
+ onSegmentTransitionEnd();
340
+ }
341
+ };
342
+ el.addEventListener('animationend', handleAnimationEnd);
343
+ return () => el.removeEventListener('animationend', handleAnimationEnd);
344
+ }, [currentIndexDisplayed, onSegmentTransitionEnd]);
298
345
  return (React.createElement("div", { ref: panelRef, className: cn('jgis-story-viewer-panel', className), id: "jgis-story-segment-panel" },
299
- React.createElement("div", { key: currentIndexDisplayed, className: "jgis-story-segment-container", style: {
346
+ React.createElement("div", { ref: topSentinelRef, "aria-hidden": true, "data-story-scroll-sentinel": "top", style: { height: 1, minHeight: 1, pointerEvents: 'none' } }),
347
+ React.createElement("div", { ref: segmentContainerRef, key: currentIndexDisplayed, className: "jgis-story-segment-container", style: {
300
348
  animationDuration: `${transitionTime}s`,
301
349
  } },
302
350
  React.createElement("div", { id: "jgis-story-segment-header" },
@@ -307,7 +355,8 @@ const StoryViewerPanel = forwardRef(({ model, isSpecta, isMobile = false, classN
307
355
  ? navSlot
308
356
  : null })),
309
357
  React.createElement("div", { id: "jgis-story-segment-content" },
310
- React.createElement(StoryContentSection, { markdown: (_o = (_m = activeSlide === null || activeSlide === void 0 ? void 0 : activeSlide.content) === null || _m === void 0 ? void 0 : _m.markdown) !== null && _o !== void 0 ? _o : '' })))));
358
+ React.createElement(StoryContentSection, { markdown: (_o = (_m = activeSlide === null || activeSlide === void 0 ? void 0 : activeSlide.content) === null || _m === void 0 ? void 0 : _m.markdown) !== null && _o !== void 0 ? _o : '' }))),
359
+ React.createElement("div", { ref: bottomSentinelRef, "aria-hidden": true, "data-story-scroll-sentinel": "bottom", style: { height: 1, minHeight: 1, pointerEvents: 'none' } })));
311
360
  });
312
361
  StoryViewerPanel.displayName = 'StoryViewerPanel';
313
362
  export default StoryViewerPanel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupytergis/base",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "A JupyterLab extension for 3D modelling.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -44,7 +44,7 @@
44
44
  "@jupyter/collaboration": "^4",
45
45
  "@jupyter/react-components": "^0.16.6",
46
46
  "@jupyter/ydoc": "^2.0.0 || ^3.0.0",
47
- "@jupytergis/schema": "^0.13.1",
47
+ "@jupytergis/schema": "^0.13.2",
48
48
  "@jupyterlab/application": "^4.3.0",
49
49
  "@jupyterlab/apputils": "^4.3.0",
50
50
  "@jupyterlab/completer": "^4.3.0",
@@ -5,6 +5,7 @@
5
5
  align-items: center;
6
6
  background-color: var(--jp-layout-color0);
7
7
  }
8
+
8
9
  .jgis-tabs-list {
9
10
  display: inline-flex;
10
11
  height: 2.5rem;
@@ -12,12 +13,17 @@
12
13
  justify-content: space-evenly;
13
14
  background-color: var(--jp-layout-color2);
14
15
  color: var(--jp-ui-font-color0);
16
+ cursor: grab;
15
17
  gap: 1rem;
16
18
  width: 100%;
17
19
  font-size: 9px;
18
20
  overflow-x: scroll;
19
21
  }
20
22
 
23
+ .jgis-tabs-list:active {
24
+ cursor: grabbing;
25
+ }
26
+
21
27
  .jgis-tabs-trigger {
22
28
  display: inline-flex;
23
29
  align-items: center;