diff --git a/browser/css/partsPreviewControl.css b/browser/css/partsPreviewControl.css index 87563f5d866e8..de1968b0eb268 100644 --- a/browser/css/partsPreviewControl.css +++ b/browser/css/partsPreviewControl.css @@ -61,3 +61,62 @@ .hidden-slide { opacity: 0.5; } + +/* Slide section headers - styled to match sidebar expander labels */ +.slide-section-header { + display: flex; + align-items: center; + padding-inline: 8px; + line-height: var(--sidebar-header-height, 34px); + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--color-border, #ccc); +} + +.slide-section-toggle { + background: transparent; + border: 0; + padding: 0; + margin-inline-end: 7px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex: 0 0 auto; + color: inherit; +} + +.slide-section-toggle::before { + content: ''; + display: inline-block; + width: 9px; + height: 9px; + background: transparent url('images/lc_menu_chevron.svg') no-repeat center; + filter: brightness(0.5); + transform: rotate(90deg); /* expanded: chevron points down */ + transition: transform 0.1s ease-in; +} + +.slide-section-header.collapsed .slide-section-toggle::before { + transform: rotate(0deg); /* collapsed: chevron points right */ +} + +[data-theme='dark'] .slide-section-toggle::before { + filter: brightness(1); +} + +.slide-section-name { + color: var(--color-main-text); + font-size: var(--header-font-size, 12px); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.preview-frame.section-collapsed { + display: none; +} diff --git a/browser/src/control/Control.PartsPreview.js b/browser/src/control/Control.PartsPreview.js index dc810578488a7..968bedb901064 100644 --- a/browser/src/control/Control.PartsPreview.js +++ b/browser/src/control/Control.PartsPreview.js @@ -55,6 +55,8 @@ window.L.Control.PartsPreview = window.L.Control.extend({ onAdd: function (map) { this._previewInitialized = false; this._previewTiles = []; + this._sectionHeaders = []; // Section header DOM elements + this._collapsedSections = new Set(); // Names of sections collapsed by the user this._direction = this.options.allowOrientation ? (!window.mode.isDesktop() && window.L.DomUtil.isPortrait() ? 'x' : 'y') : this.options.axis; @@ -69,6 +71,7 @@ window.L.Control.PartsPreview = window.L.Control.extend({ map.on('scrolllimits', this._invalidateParts, this); map.on('scrolltopart', this._scrollToPart, this); map.on('beforerequestpreview', this._beforeRequestPreview, this); + map.on('updatesections', this._updateSections, this); window.addEventListener('resize', window.L.bind(this._resize, this)); }, @@ -402,6 +405,26 @@ window.L.Control.PartsPreview = window.L.Control.extend({ }); } + // if not the first section slide then add entry for section + var isFisrtSectionSlide = false; + const sections = app.impress.sections; + if (sections) { + for (let i = 0; i < sections.length; i++) { + if (sections[i].startIndex === partIndex) { + isFisrtSectionSlide = true; + break; + } + } + } + if (!isFisrtSectionSlide) { + entries.push({ + id: 'addsection', + type: 'comboboxentry', + text: _('Add Section'), + pos: 0, + }); + } + var menuPosEl = that._getMenuPosEl(); var rect = that._container.getBoundingClientRect(); menuPosEl.style.left = (e.clientX - rect.left) + 'px'; @@ -438,6 +461,9 @@ window.L.Control.PartsPreview = window.L.Control.extend({ case 'hideslide': that._map.hideSlide(); break; + case 'addsection': + app.socket.sendMessage('uno .uno:AddSlideSection'); + app.socket.sendMessage('getslidesections'); } JSDialog.CloseAllDropdowns(); return true; @@ -469,10 +495,274 @@ window.L.Control.PartsPreview = window.L.Control.extend({ return img; }, + _updateSections: function (e) { + if (!this._previewInitialized) + return; + + var sections = e.sections || []; + + // Remove existing section headers + for (var i = 0; i < this._sectionHeaders.length; i++) { + window.L.DomUtil.remove(this._sectionHeaders[i]); + } + this._sectionHeaders = []; + + if (!sections || sections.length === 0) { + this._collapsedSections.clear(); + return; + } + + // Drop any remembered names that no longer correspond to a section + // (e.g. after a rename or removal). + var liveNames = new Set(); + for (var ln = 0; ln < sections.length; ln++) + liveNames.add(sections[ln].name); + this._collapsedSections.forEach(function (name) { + if (!liveNames.has(name)) + this._collapsedSections.delete(name); + }, this); + + // Insert section headers before the frame of each section's first slide. + // The container children are: #first-drop-site, frame0, frame1, ... + // So slide index N corresponds to child index N+1. + for (var s = 0; s < sections.length; s++) { + var section = sections[s]; + var slideIndex = section.startIndex; + + if (slideIndex < 0 || slideIndex >= this._previewTiles.length) + continue; + + var header = this._createSectionHeader(section, s); + this._sectionHeaders.push(header); + + // Insert before the frame of this section's first slide + var slideFrame = this._previewTiles[slideIndex].parentNode; + slideFrame.parentNode.insertBefore(header, slideFrame); + } + + this._applyAllSectionsCollapse(); + }, + + _createSectionHeader: function (section, sectionIndex) { + var that = this; + + var header = window.L.DomUtil.create('div', 'slide-section-header'); + header.setAttribute('data-section-index', sectionIndex); + header.setAttribute('data-start-index', section.startIndex); + + var toggleBtn = window.L.DomUtil.create('button', 'slide-section-toggle ui-expander-btn', header); + toggleBtn.type = 'button'; + toggleBtn.setAttribute('aria-label', + _('Toggle section %1').replace('%1', section.name)); + + var nameSpan = window.L.DomUtil.create('span', 'slide-section-name', header); + nameSpan.textContent = section.name; + nameSpan.setAttribute('title', section.name); + + window.L.DomEvent.on(toggleBtn, 'click', function (e) { + window.L.DomEvent.stopPropagation(e); + window.L.DomEvent.preventDefault(e); + that._toggleSectionCollapse(sectionIndex); + }, this); + + // Click on the header (but not the toggle) selects all slides in the section. + window.L.DomEvent.on(header, 'click', function (e) { + if (toggleBtn.contains(e.target)) + return; + window.L.DomEvent.stopPropagation(e); + window.L.DomEvent.preventDefault(e); + that._selectSection(sectionIndex); + }, this); + + // Section context menu + if (this._map.isEditMode()) { + window.L.DomEvent.on(header, 'contextmenu', function(e) { + window.L.DomEvent.stopPropagation(e); + window.L.DomEvent.preventDefault(e); + + if (app.map.isReadOnlyMode()) + return; + + that._openSectionContextMenu(section, sectionIndex, e); + }, this); + } + + return header; + }, + + _selectSection: function (sectionIndex) { + var sections = app.impress && app.impress.sections; + if (!sections || !sections[sectionIndex]) + return; + + var start = sections[sectionIndex].startIndex; + var end = (sectionIndex + 1 < sections.length) + ? sections[sectionIndex + 1].startIndex - 1 + : this._previewTiles.length - 1; + + if (start < 0 || end < start) + return; + + this._selectPartRange(start, end, false); + }, + + _toggleSectionCollapse: function (sectionIndex) { + var sections = app.impress.sections || []; + var section = sections[sectionIndex]; + if (!section) + return; + + if (this._collapsedSections.has(section.name)) + this._collapsedSections.delete(section.name); + else + this._collapsedSections.add(section.name); + + this._applySectionCollapse(sectionIndex); + // Expanding may reveal thumbnails whose images were never fetched. + this._ensureVisiblePreviews(); + }, + + // Apply the collapsed class to one section's header and its slide frames. + _applySectionCollapse: function (sectionIndex) { + var section = app.impress.sections && app.impress.sections[sectionIndex]; + if (!section) + return; + + var collapsed = this._collapsedSections.has(section.name); + var end = section.startIndex + section.slideCount; + + var header = this._sectionHeaders[sectionIndex]; + if (header) { + var toggleBtn = header.querySelector('.slide-section-toggle'); + header.classList.toggle('collapsed', collapsed); + if (toggleBtn) + toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + } + + for (var i = section.startIndex; i < end; i++) { + var frame = this._previewTiles[i] && this._previewTiles[i].parentNode; + if (frame) + frame.classList.toggle('section-collapsed', collapsed); + } + }, + + // Apply collapsed state to every section. + _applyAllSectionsCollapse: function () { + var sections = app.impress.sections || []; + for (var s = 0; s < sections.length; s++) + this._applySectionCollapse(s); + }, + + _openSectionContextMenu: function (section, sectionIndex, e) { + var that = this; + var sections = app.impress.sections || []; + + var entries = [{ + id: 'renameSection', + type: 'comboboxentry', + text: _('Rename Section'), + pos: 0, + }]; + if (sectionIndex > 0) { + entries.push({ + id: 'moveSectionUp', + type: 'comboboxentry', + text: _('Move Section Up'), + pos: 0, + }); + } + if (sectionIndex < sections.length - 1) { + entries.push({ + id: 'moveSectionDown', + type: 'comboboxentry', + text: _('Move Section Down'), + pos: 0, + }); + } + entries.push({ + id: 'removeSection', + type: 'comboboxentry', + text: _('Remove Section'), + pos: 0, + }); + + var menuPosEl = this._getMenuPosEl(); + var rect = this._container.getBoundingClientRect(); + menuPosEl.style.left = (e.clientX - rect.left) + 'px'; + menuPosEl.style.top = (e.clientY - rect.top) + 'px'; + + var callback = function (objectType, eventType, object, data, entry) { + if (eventType !== 'selected') + return false; + switch (entry.id) { + case 'renameSection': + that._renameSection(section, sectionIndex); + break; + case 'moveSectionUp': + that._map.setPart(section.startIndex); + that._map.selectPart(section.startIndex, 1, false); + app.socket.sendMessage('uno .uno:MoveSlideSectionUp'); + app.socket.sendMessage('getslidesections'); + break; + case 'moveSectionDown': + that._map.setPart(section.startIndex); + that._map.selectPart(section.startIndex, 1, false); + app.socket.sendMessage('uno .uno:MoveSlideSectionDown'); + app.socket.sendMessage('getslidesections'); + break; + case 'removeSection': + that._map.setPart(section.startIndex); + that._map.selectPart(section.startIndex, 1, false); + app.socket.sendMessage('uno .uno:RemoveSlideSection'); + app.socket.sendMessage('getslidesections'); + break; + } + JSDialog.CloseAllDropdowns(); + return true; + }; + + JSDialog.OpenDropdown( + 'slide-section-menu', + menuPosEl, + entries, + callback, + '', + false, + ); + }, + + _renameSection: function (section, sectionIndex) { + var currentName = section.name; + + app.map.uiManager.showInputModal( + 'rename-section', + _('Rename Section'), + _('Enter new section name:'), + currentName, + _('OK'), + function (newName) { + if (newName && newName !== currentName) { + var command = { + 'SectionIndex': { + 'type': 'long', + 'value': sectionIndex + }, + 'Name': { + 'type': 'string', + 'value': newName + } + }; + app.socket.sendMessage('uno .uno:RenameSlideSection ' + JSON.stringify(command)); + app.socket.sendMessage('getslidesections'); + } + } + ); + }, + _scrollToPart: function(part) { var partNo = part !== undefined ? part : this._map.getCurrentPartNumber(); - //var sliderSize, nodePos, nodeOffset, nodeMargin; - var node = this._partsPreviewCont.children[partNo]; + // Use the preview tile's parent frame directly instead of child index + var node = this._previewTiles[partNo] ? this._previewTiles[partNo].parentNode : null; if (node && (!this._previewTiles[partNo] || !this._isPreviewVisible(partNo))) { if (this.scrollTimer) clearTimeout(this.scrollTimer); @@ -484,12 +774,17 @@ window.L.Control.PartsPreview = window.L.Control.extend({ } }, - // We will use this function because IE doesn't support "Array.from" feature. + // Returns the logical child index (counting only frames, not section headers). _findClickedPart: function (element) { + var frameIndex = 0; for (var i = 0; i < this._partsPreviewCont.children.length; i++) { - if (this._partsPreviewCont.children[i] === element || this._partsPreviewCont.children[i] === element.parentNode) { - return i; + var child = this._partsPreviewCont.children[i]; + if (child === element || child === element.parentNode) { + return frameIndex; } + // Only count non-section-header children as frames + if (!child.classList.contains('slide-section-header')) + frameIndex++; } return -1; }, @@ -624,7 +919,7 @@ window.L.Control.PartsPreview = window.L.Control.extend({ } }, - _selectPartRange: function (start, end) { + _selectPartRange: function (start, end, scrollToEnd = true) { if (start === undefined || start === null) start = this._map._docLayer._selectedPart; @@ -649,7 +944,8 @@ window.L.Control.PartsPreview = window.L.Control.extend({ } } this._selectedPartRange = [start, end]; - this._scrollToPart(end); + if (scrollToEnd) + this._scrollToPart(end); }, _modifySelectedPartRange: function (direction) { diff --git a/browser/src/docstate.ts b/browser/src/docstate.ts index de80a7c610bc0..30bec3165180c 100644 --- a/browser/src/docstate.ts +++ b/browser/src/docstate.ts @@ -58,6 +58,12 @@ }, impress: { partList: null, // Info for parts. + sections: null as Array<{ + name: string; + id?: string; + startIndex: number; + slideCount: number; + }> | null, hasOverviewPage: false, //Whether the file has an overview page notesMode: false, // Opposite of "NormalMultiPaneGUI". twipsCorrection: 0.567, // There is a constant ratio between tiletwips and impress page twips. For now, this seems safe to use. diff --git a/browser/src/layer/tile/CanvasTileLayer.js b/browser/src/layer/tile/CanvasTileLayer.js index c1d39f8a40d17..87dbcd96cf37c 100644 --- a/browser/src/layer/tile/CanvasTileLayer.js +++ b/browser/src/layer/tile/CanvasTileLayer.js @@ -1275,6 +1275,10 @@ window.L.CanvasTileLayer = window.L.Layer.extend({ } else if (textMsg.startsWith('presentationinfo:')) { var content = JSON.parse(textMsg.substring('presentationinfo:'.length + 1)); this._map.fire('presentationinfo', content); + } else if (textMsg.startsWith('slidesections:')) { + var sections = JSON.parse(textMsg.substring('slidesections:'.length + 1)); + app.impress.sections = sections; + this._map.fire('updatesections', {sections: sections}); } else if (textMsg.startsWith('slideshowfollow')) { const eventInfo = textMsg.substr('slideshowfollow '.length); const parameterStartIndex = eventInfo.indexOf('{'); diff --git a/browser/src/layer/tile/ImpressTileLayer.js b/browser/src/layer/tile/ImpressTileLayer.js index b42f133f9987d..8f2ce5356c0c0 100644 --- a/browser/src/layer/tile/ImpressTileLayer.js +++ b/browser/src/layer/tile/ImpressTileLayer.js @@ -333,6 +333,9 @@ window.L.ImpressTileLayer = window.L.CanvasTileLayer.extend({ if (refreshAnnotation) app.socket.sendMessage('commandvalues command=.uno:ViewAnnotations'); + + // Fetch slide sections data + app.socket.sendMessage('getslidesections'); } this._documentInfo = textMsg; diff --git a/cypress_test/data/desktop/impress/slide-section-test.odp b/cypress_test/data/desktop/impress/slide-section-test.odp new file mode 100644 index 0000000000000..c52fc034fa731 Binary files /dev/null and b/cypress_test/data/desktop/impress/slide-section-test.odp differ diff --git a/cypress_test/data/desktop/impress/slide-section-test.pptx b/cypress_test/data/desktop/impress/slide-section-test.pptx new file mode 100644 index 0000000000000..47354deb97446 Binary files /dev/null and b/cypress_test/data/desktop/impress/slide-section-test.pptx differ diff --git a/cypress_test/integration_tests/desktop/impress/slide_sections_spec.js b/cypress_test/integration_tests/desktop/impress/slide_sections_spec.js new file mode 100644 index 0000000000000..f7093d9f63059 --- /dev/null +++ b/cypress_test/integration_tests/desktop/impress/slide_sections_spec.js @@ -0,0 +1,278 @@ +/* -*- js-indent-level: 8 -*- */ +/* global describe it cy require beforeEach expect */ + +var helper = require('../../common/helper'); +var desktopHelper = require('../../common/desktop_helper'); + +describe(['tagdesktop', 'tagnextcloud', 'tagproxy'], 'Slide sections', function() { + + function assertSectionHeaders(names) { + cy.cGet('.slide-section-header .slide-section-name').should('have.length', names.length); + for (var i = 0; i < names.length; i++) { + cy.cGet('.slide-section-header .slide-section-name').eq(i).should('have.text', names[i]); + } + } + + function rightClickSectionHeader(index) { + cy.cGet('.slide-section-header').eq(index).rightclick(); + } + + function clickContextMenuItem(text) { + cy.cGet('[id$="-dropdown"]:visible') + .contains('.ui-combobox-entry', text).click(); + } + + function renameSection(sectionIndex, newName) { + rightClickSectionHeader(sectionIndex); + clickContextMenuItem('Rename Section'); + cy.cGet('#input-modal input').clear().type(newName); + cy.cGet('#response-ok').click(); + } + + describe('PPTX format', function() { + + beforeEach(function() { + this.newFilePath = helper.setupAndLoadDocument('impress/slide-section-test.pptx'); + desktopHelper.switchUIToNotebookbar(); + cy.getFrameWindow().then((win) => { + this.win = win; + }); + }); + + it('Open PPTX file with sections', function() { + helper.processToIdle(this.win); + + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3']); + }); + + it('Add section in PPTX', function() { + helper.processToIdle(this.win); + + // Right-click on the last slide which is not a section start + cy.cGet('.preview-img').last().rightclick(); + clickContextMenuItem('Add Section'); + helper.processToIdle(this.win); + + // Verify new section added with default name + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3', 'Untitled Section']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3', 'Untitled Section']); + }); + + it('Rename section in PPTX', function() { + helper.processToIdle(this.win); + + // Rename the first section + renameSection(0, 'Renamed Section'); + helper.processToIdle(this.win); + + assertSectionHeaders(['Renamed Section', 'Section-2', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Renamed Section', 'Section-2', 'Section-3']); + }); + + it('Move section up in PPTX', function() { + helper.processToIdle(this.win); + + // Move second section up + rightClickSectionHeader(1); + clickContextMenuItem('Move Section Up'); + helper.processToIdle(this.win); + + // Verify Section-2 is now first, Section-1 is second + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + }); + + it('Move section down in PPTX', function() { + helper.processToIdle(this.win); + + // Move first section down + rightClickSectionHeader(0); + clickContextMenuItem('Move Section Down'); + helper.processToIdle(this.win); + + // Verify Section-2 is now first, Section-1 is second + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + }); + + it('Section slide selection', function() { + helper.processToIdle(this.win); + + // 3 sections: Section-1 (slides 1-4), Section-2 (5-11), Section-3 (12-13). + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3']); + + // Click the body of Section-2's header (anywhere that isn't the toggle). + cy.cGet('.slide-section-header').eq(1) + .find('.slide-section-name').click(); + helper.processToIdle(this.win); + + cy.window().then((win) => { + var impress = win['0'].app.impress; + // Slides in Section-2 (indices 4-10) should be selected. + for (var i = 4; i <= 10; i++) + expect(impress.isSlideSelected(i)).to.be.true; + + // Slides outside Section-2 should not be selected. + [0, 1, 2, 3, 11, 12].forEach(function (i) { + expect(impress.isSlideSelected(i)).to.be.false; + }); + }); + }); + + it('Collapse section hides its slides', function() { + helper.processToIdle(this.win); + + // Starting layout (13 slides, 3 sections): Section-1 (1-4), Section-2 (5-11), Section-3 (12-13) + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3']); + + // Add a 4th section starting at slide 3 + cy.cGet('#preview-img-part-2').scrollIntoView().rightclick(); + clickContextMenuItem('Add Section'); + helper.processToIdle(this.win); + // Now: Section-1 (1-2), Untitled Section (3-4), Section-2 (5-11), Section-3 (12-13) + assertSectionHeaders(['Section-1', 'Untitled Section', 'Section-2', 'Section-3']); + + // Collapse the new Untitled Section (index 1) + cy.cGet('.slide-section-header').eq(1).find('.slide-section-toggle').click(); + + [2, 3].forEach(function (i) { + cy.cGet('#preview-frame-part-' + i).should('have.class', 'section-collapsed'); + }); + + // Collapse Section-3 (index 3) + cy.cGet('.slide-section-header').eq(3).find('.slide-section-toggle').click(); + [2, 3, 11, 12].forEach(function (i) { + cy.cGet('#preview-frame-part-' + i).should('have.class', 'section-collapsed'); + }); + + // Add another section at slide 8 (index 7, inside Section-2). Slide 8 is + // inside Section-2 which is visible. + cy.cGet('#preview-img-part-7').scrollIntoView(); + cy.cGet('#preview-img-part-7').rightclick(); + clickContextMenuItem('Add Section'); + helper.processToIdle(this.win); + // Layout: Section-1 (1-2), Untitled (3-4), Section-2 (5-7), Untitled (8-11), Section-3 (12-13) + // The second Untitled Section auto-collapses because it shares the name + // "Untitled Section" with the already-collapsed first one. + + // Check the .section-collapsed class on each slide's preview-frame + // rather than visibility - off-screen slides also fail be.visible. + // Visible: Section-1 (slides 1-2 = index 0,1), Section-2 (slides 5-7 = index 4,5,6) + [0, 1, 4, 5, 6].forEach(function (i) { + cy.cGet('#preview-frame-part-' + i).should('not.have.class', 'section-collapsed'); + }); + + // Hidden: both Untitled Sections (indices 2,3,7,8,9,10) and Section-3 (indices 11,12) + [2, 3, 7, 8, 9, 10, 11, 12].forEach(function (i) { + cy.cGet('#preview-frame-part-' + i).should('have.class', 'section-collapsed'); + }); + }); + }); + + describe('ODP format', function() { + + beforeEach(function() { + this.newFilePath = helper.setupAndLoadDocument('impress/slide-section-test.odp'); + desktopHelper.switchUIToNotebookbar(); + cy.getFrameWindow().then((win) => { + this.win = win; + }); + }); + + it('Open ODP file with sections', function() { + helper.processToIdle(this.win); + + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3']); + }); + + it('Add section in ODP', function() { + helper.processToIdle(this.win); + + // Right-click on the last slide which is not a section start + cy.cGet('.preview-img').last().rightclick(); + clickContextMenuItem('Add Section'); + helper.processToIdle(this.win); + + // Verify new section added with default name + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3', 'Untitled Section']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-1', 'Section-2', 'Section-3', 'Untitled Section']); + }); + + it('Rename section in ODP', function() { + helper.processToIdle(this.win); + + // Rename the first section + renameSection(0, 'Renamed Section'); + helper.processToIdle(this.win); + + assertSectionHeaders(['Renamed Section', 'Section-2', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Renamed Section', 'Section-2', 'Section-3']); + }); + + it('Move section up in ODP', function() { + helper.processToIdle(this.win); + + // Move second section up + rightClickSectionHeader(1); + clickContextMenuItem('Move Section Up'); + helper.processToIdle(this.win); + + // Verify Section-2 is now first, Section-1 is second + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + }); + + it('Move section down in ODP', function() { + helper.processToIdle(this.win); + + // Move first section down + rightClickSectionHeader(0); + clickContextMenuItem('Move Section Down'); + helper.processToIdle(this.win); + + // Verify Section-2 is now first, Section-1 is second + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + + // Reload and verify persistence + helper.processToIdle(this.win); + helper.reloadDocument(this.newFilePath); + + assertSectionHeaders(['Section-2', 'Section-1', 'Section-3']); + }); + }); +}); diff --git a/kit/ChildSession.cpp b/kit/ChildSession.cpp index bcfde62fa6de1..35d121ac6ca7b 100644 --- a/kit/ChildSession.cpp +++ b/kit/ChildSession.cpp @@ -606,7 +606,8 @@ bool ChildSession::_handleInput(const char *buffer, int length) tokens.equals(0, "geta11yfocusedparagraph") || tokens.equals(0, "geta11ycaretposition") || tokens.equals(0, "toggletiledumping") || - tokens.equals(0, "getpresentationinfo")); + tokens.equals(0, "getpresentationinfo") || + tokens.equals(0, "getslidesections")); ProfileZone pz("ChildSession::_handleInput:" + tokens[0]); if (tokens.equals(0, "clientzoom")) @@ -900,6 +901,10 @@ bool ChildSession::_handleInput(const char *buffer, int length) { return getPresentationInfo(); } + else if (tokens.equals(0, "getslidesections")) + { + return getSlideSections(); + } else { assert(Util::isFuzzing() && "Unknown command token."); @@ -3336,6 +3341,45 @@ bool ChildSession::getPresentationInfo() return true; } +bool ChildSession::getSlideSections() +{ + getLOKitDocument()->setView(_viewId); + + LOKitHelper::ScopedString info(getLOKitDocument()->getPresentationInfo()); + if (!info || !info.get()) + { + sendTextFrame("slidesections: []"); + return true; + } + + std::string data(info.get()); + + // Extract just the "sections" array from the full presentation info + try + { + Poco::JSON::Parser parser; + auto result = parser.parse(data); + auto obj = result.extract(); + if (obj && obj->has("sections")) + { + std::ostringstream oss; + obj->getArray("sections")->stringify(oss); + sendTextFrame("slidesections: " + oss.str()); + } + else + { + sendTextFrame("slidesections: []"); + } + } + catch (const std::exception& e) + { + LOG_ERR("Failed to parse presentation info for sections: " << e.what()); + sendTextFrame("slidesections: []"); + } + + return true; +} + /* If the user is inactive we have to remember important events so that when * the user becomes active again, we can replay the events. */ diff --git a/kit/ChildSession.hpp b/kit/ChildSession.hpp index 52d486425fbee..452f7f47a6768 100644 --- a/kit/ChildSession.hpp +++ b/kit/ChildSession.hpp @@ -229,6 +229,7 @@ class ChildSession final : public Session bool getA11yFocusedParagraph(); bool getA11yCaretPosition(); bool getPresentationInfo(); + bool getSlideSections(); void rememberEventsForInactiveUser(int type, const std::string& payload); diff --git a/wsd/ClientSession.cpp b/wsd/ClientSession.cpp index 1b096f7e9f945..69f20b5979f5d 100644 --- a/wsd/ClientSession.cpp +++ b/wsd/ClientSession.cpp @@ -1406,6 +1406,7 @@ bool ClientSession::_handleInput(const char *buffer, int length) tokens.equals(0, "geta11yfocusedparagraph") || tokens.equals(0, "geta11ycaretposition") || tokens.equals(0, "getpresentationinfo") || + tokens.equals(0, "getslidesections") || tokens.equals(0, "slideshowfollow")) { #if !MOBILEAPP @@ -2660,6 +2661,10 @@ bool ClientSession::handleKitToClientMessage(const std::shared_ptr& pay { return handlePresentationInfo(payload, docBroker); } + else if (tokens.equals(0, "slidesections:")) + { + return forwardToClient(payload); + } else if (tokens.equals(0, "clipboardcontent:")) { #if !MOBILEAPP // Most likely nothing of this makes sense in a mobile app diff --git a/wsd/protocol.txt b/wsd/protocol.txt index e9287581756d9..5065d3bea3b69 100644 --- a/wsd/protocol.txt +++ b/wsd/protocol.txt @@ -509,6 +509,11 @@ getpresentationinfo This change uses the new GetPresentationInfo() funciton that was added to the LOKit API. +getslidesections + + Returns the slide sections for an Impress presentation as a JSON array. + Response: slidesections: [{name, id, startIndex, slideCount}, ...] + removetextcontext id= before= after= This protocol is used to remove text context from a specified location in a document. It allows the deletion of multiple characters within a range by specifying the positions before and after the deletion.