@jupytergis/base 0.13.1 → 0.13.3

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,43 +361,34 @@ 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, Object.assign({ label: trans.__('Rename'), isEnabled: () => {
365
+ var _a, _b, _c;
368
366
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
369
- await Private.renameSelectedItem(model, 'layer');
370
- },
371
- });
372
- commands.addCommand(CommandIDs.removeLayer, {
373
- label: trans.__('Remove Layer'),
374
- execute: () => {
375
- var _a;
367
+ 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;
368
+ return !!selected && Object.keys(selected).length === 1;
369
+ }, execute: async () => {
370
+ var _a, _b, _c;
376
371
  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);
381
- },
382
- });
383
- commands.addCommand(CommandIDs.renameGroup, {
384
- label: trans.__('Rename Group'),
385
- execute: async () => {
386
- var _a;
372
+ 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;
373
+ if (!model || !selected) {
374
+ return;
375
+ }
376
+ await Private.renameSelectedItem(model);
377
+ } }, icons.get(CommandIDs.renameSelected)));
378
+ commands.addCommand(CommandIDs.removeSelected, Object.assign({ label: trans.__('Remove'), isEnabled: () => {
379
+ var _a, _b, _c;
387
380
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
388
- await Private.renameSelectedItem(model, 'group');
389
- },
390
- });
391
- commands.addCommand(CommandIDs.removeGroup, {
392
- label: trans.__('Remove Group'),
393
- execute: async () => {
394
- var _a;
381
+ 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;
382
+ return !!selected && Object.keys(selected).length > 0;
383
+ }, execute: async () => {
384
+ var _a, _b, _c;
395
385
  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
- });
399
- },
400
- });
386
+ 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;
387
+ if (!model || !selected) {
388
+ return;
389
+ }
390
+ await Private.removeSelectedItems(model);
391
+ } }, icons.get(CommandIDs.removeSelected)));
401
392
  commands.addCommand(CommandIDs.moveLayersToGroup, {
402
393
  label: args => args['label'] ? args['label'] : trans.__('Move to Root'),
403
394
  execute: args => {
@@ -473,7 +464,7 @@ export function addCommands(app, tracker, translator, formSchemaRegistry, layerB
473
464
  execute: async () => {
474
465
  var _a;
475
466
  const model = (_a = tracker.currentWidget) === null || _a === void 0 ? void 0 : _a.model;
476
- await Private.renameSelectedItem(model, 'source');
467
+ await Private.renameSelectedItem(model);
477
468
  },
478
469
  });
479
470
  commands.addCommand(CommandIDs.removeSource, {
@@ -481,15 +472,7 @@ export function addCommands(app, tracker, translator, formSchemaRegistry, layerB
481
472
  execute: () => {
482
473
  var _a;
483
474
  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
- });
475
+ Private.removeSelectedSources(model);
493
476
  },
494
477
  });
495
478
  // Console commands
@@ -934,42 +917,60 @@ var Private;
934
917
  };
935
918
  }
936
919
  Private.createEntry = createEntry;
937
- function removeSelectedItems(model, itemTypeToRemove, removeFunction) {
920
+ function removeSelectedItems(model) {
938
921
  var _a, _b;
939
922
  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) {
923
+ if (!selected || !model) {
941
924
  console.error('Failed to remove selected item -- nothing selected');
942
925
  return;
943
926
  }
944
- for (const selection in selected) {
945
- if (selected[selection].type === itemTypeToRemove) {
946
- removeFunction(selection);
927
+ for (const id of Object.keys(selected)) {
928
+ const item = selected[id];
929
+ switch (item.type) {
930
+ case 'layer':
931
+ model.removeLayer(id);
932
+ break;
933
+ case 'group':
934
+ model.removeLayerGroup(id);
935
+ break;
947
936
  }
948
937
  }
949
938
  }
950
939
  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;
940
+ async function renameSelectedItem(model) {
941
+ var _a, _b;
942
+ 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
943
  if (!selectedItems || !model) {
955
- console.error(`No ${itemType} selected`);
944
+ console.error('No item selected');
956
945
  return;
957
946
  }
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
- }
947
+ const ids = Object.keys(selectedItems);
948
+ if (ids.length === 0) {
949
+ return;
965
950
  }
966
- if (!itemId) {
951
+ const itemId = ids[0];
952
+ const item = selectedItems[itemId];
953
+ if (!item.type) {
967
954
  return;
968
955
  }
969
- // Set editing state - component will show inline input
970
- model.setEditingItem(itemType, itemId);
956
+ model.setEditingItem(item.type, itemId);
971
957
  }
972
958
  Private.renameSelectedItem = renameSelectedItem;
959
+ function removeSelectedSources(model) {
960
+ var _a, _b;
961
+ 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;
962
+ if (!selected || !model) {
963
+ return;
964
+ }
965
+ for (const id of Object.keys(selected)) {
966
+ if (model.getLayersBySource(id).length > 0) {
967
+ showErrorMessage('Remove source error', 'The source is used by a layer.');
968
+ continue;
969
+ }
970
+ model.sharedModel.removeSource(id);
971
+ }
972
+ }
973
+ Private.removeSelectedSources = removeSelectedSources;
973
974
  function executeConsole(tracker) {
974
975
  const current = tracker.currentWidget;
975
976
  if (!current || !(current instanceof JupyterGISDocumentWidget)) {
package/lib/constants.js CHANGED
@@ -39,6 +39,8 @@ const iconObject = {
39
39
  [CommandIDs.toggleStoryPresentationMode]: {
40
40
  iconClass: 'fa fa-book jgis-icon-adjust',
41
41
  },
42
+ [CommandIDs.renameSelected]: { iconClass: 'fa fa-pen' },
43
+ [CommandIDs.removeSelected]: { iconClass: 'fa fa-trash' },
42
44
  };
43
45
  /**
44
46
  * The registered icons
@@ -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;
@@ -659,7 +663,7 @@ export class MainView extends React.Component {
659
663
  displayTemporalController: false,
660
664
  filterStates: {},
661
665
  jgisSettings: this._model.jgisSettings,
662
- isSpectaPresentation: false,
666
+ isSpectaPresentation: this._model.isSpectaMode(),
663
667
  };
664
668
  this._sources = [];
665
669
  this._loadingLayers = new Set();
@@ -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/lib/widget.js CHANGED
@@ -70,10 +70,13 @@ export class JupyterGISPanel extends SplitPanel {
70
70
  super({ orientation: 'vertical', spacing: 0 });
71
71
  this._consoleOpened = false;
72
72
  this._state = state;
73
- this._initModel({ model, commandRegistry });
74
- this._initView(formSchemaRegistry, annotationModel);
75
73
  this._consoleOption = Object.assign({ commandRegistry }, consoleOption);
76
74
  this._consoleTracker = consoleTracker;
75
+ const readyPromise = model.sharedModel.initialSyncReady;
76
+ readyPromise.then(() => {
77
+ this._initModel({ model, commandRegistry });
78
+ this._initView(formSchemaRegistry, annotationModel);
79
+ });
77
80
  }
78
81
  _initModel(options) {
79
82
  this._view = new ObservableMap();
@@ -94,9 +97,15 @@ export class JupyterGISPanel extends SplitPanel {
94
97
  SplitPanel.setStretch(this._jupyterGISMainViewPanel, 1);
95
98
  }
96
99
  get jupyterGISMainViewPanel() {
100
+ if (!this._jupyterGISMainViewPanel) {
101
+ console.warn('JupyterGISPanel not ready (initialSyncReady not resolved)');
102
+ }
97
103
  return this._jupyterGISMainViewPanel;
98
104
  }
99
105
  get viewChanged() {
106
+ if (!this._view) {
107
+ console.warn('JupyterGISPanel not ready (initialSyncReady not resolved)');
108
+ }
100
109
  return this._view.changed;
101
110
  }
102
111
  /**
@@ -114,6 +123,9 @@ export class JupyterGISPanel extends SplitPanel {
114
123
  super.dispose();
115
124
  }
116
125
  get currentViewModel() {
126
+ if (!this._mainViewModel) {
127
+ console.warn('JupyterGISPanel not ready (initialSyncReady not resolved)');
128
+ }
117
129
  return this._mainViewModel;
118
130
  }
119
131
  get consolePanel() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupytergis/base",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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.3",
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;