diff --git a/src/pages/notebook.tsx b/src/pages/notebook.tsx index 4cb32d3b..695bdd6c 100644 --- a/src/pages/notebook.tsx +++ b/src/pages/notebook.tsx @@ -7,6 +7,281 @@ import { DownloadDropdownButton } from '../ui-components/DownloadDropdownButton' import { Commands } from '../commands'; import { SharingService } from '../sharing-service'; import { INotebookContent } from '@jupyterlab/nbformat'; +import { Widget } from '@lumino/widgets'; +import { IDisposable, DisposableDelegate } from '@lumino/disposable'; + +/** + * Creates a "View Only" header widget for read-only notebooks. + * @returns A Widget containing the "View Only" message. + */ +function createViewOnlyHeader(): Widget { + const widget = new Widget(); + widget.addClass('je-ViewOnlyHeader'); + const contentNode = document.createElement('div'); + contentNode.className = 'je-ViewOnlyHeader-content'; + contentNode.textContent = 'View Only'; + + widget.node.appendChild(contentNode); + + return widget; +} + +/** + * Checks if a notebook is read-only/shared based on its metadata. + * @param notebookPanel - The notebook panel to check + * @returns true if the notebook is read-only/shared, false otherwise + */ +// TODO: better typing for notebookPanel +function isReadOnlyNotebook(notebookPanel: any): boolean { + try { + const { context } = notebookPanel; + if (!context?.model) { + return false; + } + + const { model } = context; + let metadata = null; + + if (model.metadata && typeof model.metadata.get === 'function') { + const { metadata: modelMetadata } = model; + metadata = { + isSharedNotebook: modelMetadata.get('isSharedNotebook'), + sharedId: modelMetadata.get('sharedId'), + readableId: modelMetadata.get('readableId') + }; + } else if (model.metadata) { + ({ metadata } = model); + } else if (model.toJSON && model.toJSON().metadata) { + const { metadata: jsonMetadata } = model.toJSON(); + metadata = jsonMetadata; + } + + console.log('Notebook metadata:', metadata); + + return metadata?.isSharedNotebook === true; + } catch (error) { + console.warn('Error checking notebook read-only status:', error); + return false; + } +} + +/** + * Hides the "notebook is read-only" indicator from the toolbar/ + * @param notebookPanel - The notebook panel to remove the indicator from + */ +// TODO: better typing for notebookPanel +function hideReadOnlyIndicator(notebookPanel: any): void { + try { + const { toolbar } = notebookPanel; + if (toolbar?.layout?.widgets) { + const widgets = Array.from(toolbar.layout.widgets) as Widget[]; + for (const widget of widgets) { + const { node } = widget; + if ( + node && + typeof node.getAttribute === 'function' && + node.getAttribute('data-jp-item-name') === 'read-only-indicator' + ) { + if (typeof widget.hide === 'function') { + widget.hide(); + return; + } + } + } + } + } catch (error) { + console.warn('Error hiding read-only indicator:', error); + } +} + +/** +// * TODO find a better way to do this + * Applies border-radius styles for the "View Only" header in accordance with the notebook area. + * @param notebookPanel - The notebook panel to style + * @param headerWidget - The View Only header widget + */ +function applySeamlessHeaderStyling(notebookPanel: any, headerWidget: Widget): void { + try { + const { node } = notebookPanel; + const mainAreaWidget = node.querySelector('.jp-MainAreaWidget'); + + if (mainAreaWidget) { + const { style } = mainAreaWidget; + Object.assign(style, { + borderRadius: '0', + borderTopLeftRadius: '0', + borderTopRightRadius: '0', + borderBottomLeftRadius: '0', + borderBottomRightRadius: '0' + }); + } + + if (headerWidget.node) { + const { style } = headerWidget.node; + Object.assign(style, { + background: '#412c88', + backgroundColor: '#412c88', + borderRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px' + }); + + style.setProperty('border-bottom-left-radius', '0px', 'important'); + style.setProperty('border-bottom-right-radius', '0px', 'important'); + style.setProperty('border-top-left-radius', '12px', 'important'); + style.setProperty('border-top-right-radius', '12px', 'important'); + style.setProperty('background-color', '#412c88', 'important'); + } + + const contentHeader = node.querySelector('.jp-MainAreaWidget-contentHeader'); + if (contentHeader) { + const { style } = contentHeader; + Object.assign(style, { + background: 'transparent', + backgroundColor: 'transparent', + padding: '0', + margin: '0', + borderRadius: '0px', + borderTopLeftRadius: '0px', + borderTopRightRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px' + }); + } + + const possibleWhiteContainers = [ + '.jp-MainAreaWidget-contentHeader > *', + '.lm-BoxPanel-child', + '.lm-Widget' + ]; + + possibleWhiteContainers.forEach(selector => { + const containers = node.querySelectorAll(selector); + containers.forEach((container: HTMLElement) => { + if (container?.contains?.(headerWidget.node)) { + const { style } = container; + Object.assign(style, { + borderRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + background: 'transparent', + backgroundColor: 'transparent' + }); + } + }); + }); + + setTimeout(() => { + const contentSelectors = [ + '.jp-MainAreaWidget > :last-child', + '.jp-MainAreaWidget > .lm-Widget:last-child', + '.jp-NotebookPanel-notebook' + ]; + + for (const selector of contentSelectors) { + const contentElements = node.querySelectorAll(selector); + contentElements.forEach((element: HTMLElement) => { + if ( + element?.style && + !element.classList.contains('jp-Toolbar') && + !element.classList.contains('jp-MainAreaWidget-contentHeader') + ) { + const { style } = element; + Object.assign(style, { + borderRadius: '0px 0px 12px 12px', + borderTopLeftRadius: '0px', + borderTopRightRadius: '0px', + borderBottomLeftRadius: '12px', + borderBottomRightRadius: '12px' + }); + } + }); + } + + if (headerWidget.node) { + const { style } = headerWidget.node; + Object.assign(style, { + background: '#412c88', + backgroundColor: '#412c88', + borderRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px' + }); + + style.setProperty('border-bottom-left-radius', '0px', 'important'); + style.setProperty('border-bottom-right-radius', '0px', 'important'); + style.setProperty('border-top-left-radius', '12px', 'important'); + style.setProperty('border-top-right-radius', '12px', 'important'); + style.setProperty('background-color', '#412c88', 'important'); + + console.log('Re-applied aggressive header styling for persistence'); + } + }, 50); + } catch (error) { + console.warn('Error applying seamless header styling:', error); + } +} + +/** + * Adds a "View Only" header to a read-only notebook panel. + * @param notebookPanel - The notebook panel to add the header to + * @returns A disposable that can be used to remove the header + */ +function addViewOnlyHeaderToNotebook(notebookPanel: any): IDisposable | null { + try { + if (!isReadOnlyNotebook(notebookPanel)) { + console.log('Notebook is not read-only, skipping View Only header'); + return null; + } + + console.log('Adding View Only header to read-only notebook'); + + const headerWidget = createViewOnlyHeader(); + const { contentHeader, node } = notebookPanel; + + if (!contentHeader) { + console.error('NotebookPanel.contentHeader is not available'); + return null; + } + + console.log('ContentHeader available, inserting widget...'); + + node.classList.add('je-shared-notebook'); + contentHeader.insertWidget(0, headerWidget); + + if (contentHeader.node) { + const { style } = contentHeader.node; + Object.assign(style, { + display: 'flex', + flexDirection: 'column', + minHeight: 'auto' + }); + } + + setTimeout(() => { + hideReadOnlyIndicator(notebookPanel); + }, 100); + + applySeamlessHeaderStyling(notebookPanel, headerWidget); + + console.log('View Only header added successfully'); + + return new DisposableDelegate(() => { + console.log('Disposing View Only header'); + node.classList.remove('je-shared-notebook'); + if (!headerWidget.isDisposed) { + headerWidget.dispose(); + } + }); + } catch (error) { + console.error('Error adding View Only header:', error); + return null; + } +} export const notebookPlugin: JupyterFrontEndPlugin = { id: 'jupytereverywhere:notebook', @@ -17,8 +292,8 @@ export const notebookPlugin: JupyterFrontEndPlugin = { tracker: INotebookTracker, toolbarRegistry: IToolbarWidgetRegistry ) => { - const { commands, shell } = app; - const contents = app.serviceManager.contents; + const { commands, shell, serviceManager } = app; + const { contents } = serviceManager; const params = new URLSearchParams(window.location.search); let notebookId = params.get('notebook'); @@ -41,12 +316,10 @@ export const notebookPlugin: JupyterFrontEndPlugin = { console.log('Retrieving notebook from API...'); const notebookResponse = await sharingService.retrieve(id); - console.log('API Response received:', notebookResponse); // debug + console.log('API Response received:', notebookResponse); - const content: INotebookContent = notebookResponse.content; + const { content }: { content: INotebookContent } = notebookResponse; - // We make all cells read-only by setting editable: false - // by iterating over each cell in the notebook content. if (content.cells) { content.cells.forEach(cell => { cell.metadata = { @@ -56,16 +329,16 @@ export const notebookPlugin: JupyterFrontEndPlugin = { }); } + const { id: responseId, readable_id, domain_id } = notebookResponse; content.metadata = { ...content.metadata, isSharedNotebook: true, - sharedId: notebookResponse.id, - readableId: notebookResponse.readable_id, - domainId: notebookResponse.domain_id + sharedId: responseId, + readableId: readable_id, + domainId: domain_id }; - // Generate a meaningful filename for the shared notebook - const filename = `Shared_${notebookResponse.readable_id || notebookResponse.id}.ipynb`; + const filename = `Shared_${readable_id || responseId}.ipynb`; await contents.save(filename, { content, @@ -113,6 +386,79 @@ export const notebookPlugin: JupyterFrontEndPlugin = { } }; + /** + * Hook into notebook widgets to add a "View Only" header for read-only notebooks. + */ + tracker.widgetAdded.connect((sender, notebookPanel) => { + console.log('Notebook widget added, setting up View Only header check...'); + + notebookPanel.revealed + .then(() => { + console.log('Notebook revealed, waiting for metadata to load...'); + + setTimeout(() => { + console.log('Checking if notebook is read-only...'); + const disposable = addViewOnlyHeaderToNotebook(notebookPanel); + if (disposable) { + notebookPanel.disposed.connect(() => { + disposable.dispose(); + }); + } + + setTimeout(() => { + if (isReadOnlyNotebook(notebookPanel)) { + hideReadOnlyIndicator(notebookPanel); + + const { contentHeader } = notebookPanel; + const headerWidget = contentHeader?.node?.querySelector('.je-ViewOnlyHeader'); + + if (headerWidget) { + const headerElement = headerWidget as HTMLElement; + const { style } = headerElement; + + Object.assign(style, { + background: '#412c88', + backgroundColor: '#412c88', + borderRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + borderTopLeftRadius: '12px', + borderTopRightRadius: '12px' + }); + + style.setProperty('border-bottom-left-radius', '0px', 'important'); + style.setProperty('border-bottom-right-radius', '0px', 'important'); + style.setProperty('border-top-left-radius', '12px', 'important'); + style.setProperty('border-top-right-radius', '12px', 'important'); + style.setProperty('background-color', '#412c88', 'important'); + + if (contentHeader?.node) { + const { style: headerStyle } = contentHeader.node; + Object.assign(headerStyle, { + borderRadius: '0px', + borderBottomLeftRadius: '0px', + borderBottomRightRadius: '0px', + background: 'transparent', + backgroundColor: 'transparent' + }); + + headerStyle.setProperty('border-radius', '0px', 'important'); + headerStyle.setProperty('background-color', 'transparent', 'important'); + console.log('Fixed white container in final reapplication'); + } + + console.log('Re-applied MOST aggressive header styling'); + applySeamlessHeaderStyling(notebookPanel, { node: headerWidget } as Widget); + } + } + }, 200); + }, 500); + }) + .catch(error => { + console.warn('Error waiting for notebook to be revealed:', error); + }); + }); + // If a notebook ID is provided in the URL, load it; otherwise, // create a new notebook if (notebookId) { diff --git a/style/base.css b/style/base.css index e8f844c2..4d3fa251 100644 --- a/style/base.css +++ b/style/base.css @@ -62,22 +62,81 @@ font-weight: 600; } -#jp-main-dock-panel[data-mode='single-document'] { - padding: 25px !important; - background: #d8b8dc; +/* Main area widget base styles */ +.jp-MainAreaWidget > .jp-Toolbar { + border-radius: var(--je-round-corners); } -#jp-main-dock-panel[data-mode='single-document'] .jp-MainAreaWidget { +.jp-MainAreaWidget > :not(.jp-Toolbar) { border-radius: var(--je-round-corners); + margin-top: 10px; + background: white; +} + +/* View Only header */ +.je-ViewOnlyHeader { + min-height: 40px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--je-slate-blue) !important; + border-radius: var(--je-round-corners) var(--je-round-corners) 0 0 !important; + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.je-ViewOnlyHeader-content { + color: white; + font-family: var(--je-font-family); + font-size: 14px; + font-weight: 600; + text-align: center; + padding: 8px 16px; + width: 100%; +} + +/* Content header visibility */ +.jp-MainAreaWidget-contentHeader { + display: flex; + flex-direction: column; + min-height: auto; background: transparent; } -.jp-MainAreaWidget > .jp-Toolbar { - border-radius: var(--je-round-corners); +.jp-MainAreaWidget-contentHeader .je-ViewOnlyHeader { + order: -1; + flex-shrink: 0; } -.jp-MainAreaWidget > :not(.jp-Toolbar) { +/* Shared notebook styling */ +.jp-NotebookPanel.je-shared-notebook .jp-MainAreaWidget { + border-radius: 0 !important; +} + +.jp-NotebookPanel.je-shared-notebook .jp-MainAreaWidget, +.jp-NotebookPanel.je-shared-notebook .jp-MainAreaWidget-contentHeader, +.jp-NotebookPanel.je-shared-notebook .lm-BoxPanel-child { + background: transparent; +} + +.jp-NotebookPanel.je-shared-notebook .jp-MainAreaWidget-contentHeader { + background: transparent !important; + padding: 0; + margin: 0; +} + +.jp-NotebookPanel.je-shared-notebook [data-jp-item-name='read-only-indicator'] { + display: none; +} + +#jp-main-dock-panel[data-mode='single-document'] { + padding: 25px !important; + background: #d8b8dc; +} + +#jp-main-dock-panel[data-mode='single-document'] .jp-MainAreaWidget { border-radius: var(--je-round-corners); - margin-top: 10px; - background: white; + background: transparent; } diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index 574938e6..7fd6003b 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -84,6 +84,7 @@ test.describe('General', () => { }); await page.goto(`lab/index.html?notebook=${notebookId}`); + page.waitForTimeout(1000); expect( await page.locator('.jp-NotebookPanel').screenshot({