diff --git a/Kitodo/src/main/java/org/kitodo/production/forms/dataeditor/GalleryPanel.java b/Kitodo/src/main/java/org/kitodo/production/forms/dataeditor/GalleryPanel.java index 3d09eb0bfb3..ada4f3655ee 100644 --- a/Kitodo/src/main/java/org/kitodo/production/forms/dataeditor/GalleryPanel.java +++ b/Kitodo/src/main/java/org/kitodo/production/forms/dataeditor/GalleryPanel.java @@ -859,7 +859,7 @@ private void selectMedia(String physicalDivisionOrder, String stripeIndex, Strin String scrollScripts = "scrollToSelectedTreeNode();scrollToSelectedPaginationRow();"; if (GalleryViewMode.PREVIEW.equals(galleryViewMode)) { PrimeFaces.current().executeScript( - "checkScrollPosition();initializeImage();metadataEditor.gallery.mediaView.update();" + scrollScripts); + "checkScrollPosition();metadataEditor.detailMap.update();metadataEditor.gallery.mediaView.update();" + scrollScripts); } else { PrimeFaces.current().executeScript(scrollScripts); } diff --git a/Kitodo/src/main/webapp/WEB-INF/resources/css/kitodo.css b/Kitodo/src/main/webapp/WEB-INF/resources/css/kitodo.css index 91daeb15469..165f745e6f0 100644 --- a/Kitodo/src/main/webapp/WEB-INF/resources/css/kitodo.css +++ b/Kitodo/src/main/webapp/WEB-INF/resources/css/kitodo.css @@ -3365,6 +3365,9 @@ Column content left: 16px; position: absolute; top: 16px; + display: flex; + flex-direction: column; + align-items: start; } #map .ol-overlaycontainer-stopevent > .ol-zoom.ol-unselectable.ol-control { @@ -3386,6 +3389,7 @@ Column content } #map .ol-control { + display: inline-block; background: transparent; padding: 0; position: static; diff --git a/Kitodo/src/main/webapp/WEB-INF/resources/js/metadata_editor.js b/Kitodo/src/main/webapp/WEB-INF/resources/js/metadata_editor.js index ccd10ae33c0..4cd4f57e078 100644 --- a/Kitodo/src/main/webapp/WEB-INF/resources/js/metadata_editor.js +++ b/Kitodo/src/main/webapp/WEB-INF/resources/js/metadata_editor.js @@ -14,7 +14,7 @@ /*eslint new-cap: ["error", { "capIsNewExceptionPattern": "^PF" }]*/ /*eslint complexity: ["error", 10]*/ -var metadataEditor = {}; +var metadataEditor = metadataEditor || {}; metadataEditor.metadataTree = { @@ -1147,7 +1147,7 @@ metadataEditor.shortcuts = { case "PREVIEW": initialize(); scrollToSelectedThumbnail(); - changeToMapView(); + metadataEditor.detailMap.update(); break; } } diff --git a/Kitodo/src/main/webapp/WEB-INF/resources/js/ol_custom.js b/Kitodo/src/main/webapp/WEB-INF/resources/js/ol_custom.js index 049d0f7da75..69e9a63eb73 100644 --- a/Kitodo/src/main/webapp/WEB-INF/resources/js/ol_custom.js +++ b/Kitodo/src/main/webapp/WEB-INF/resources/js/ol_custom.js @@ -11,244 +11,475 @@ /* globals ol */ // jshint unused:false -// Kitodo namespace -var kitodo = {}; -kitodo.map = null; - /** - * @param {Object=} options Custom control options for Kitodo in OpenLayers - * @extends {ol.control.Rotate} - * @constructor + * Abstract class describing a custom control button for the OpenLayers map. */ -kitodo.RotateLeftControl = function(options = {}) { - var buttonLeft = document.createElement('button'); - buttonLeft.innerHTML = ""; - buttonLeft.setAttribute("type", "button"); - buttonLeft.setAttribute("title", "Rotate left"); +class CustomControl extends ol.control.Control { + + /** + * Initializes a custom control button with various options. + * + * @param {object} options the custom control options (className, icon, title, other OpenLayer options) + */ + constructor(options) { + const className = options.className; + const icon = options.icon; + const title = options.title; + + const button = document.createElement('button'); + button.innerHTML = ""; + button.setAttribute("type", "button"); + button.setAttribute("title", title); + + const element = document.createElement('div'); + element.className = className + ' ol-unselectable ol-control ol-rotate'; + element.appendChild(button); + + super({ + element, + target: options.target + }); + + button.addEventListener('click', this.handleClick.bind(this), false); + } - var this_ = this; + /** + * Abstract method that handles a click event on the button. + * + * @param {MouseEvent} event the click event + */ + handleClick(event) { + // not implemented + } +} - var handleRotateLeft = function() { - var view = this_.getMap().getView(); +/** + * Custom control that rotates the image 90 degrees to the left. + */ +class RotateLeftControl extends CustomControl { + + constructor(options) { + super(Object.assign(options || {}, { + className: "rotate-left", + icon: "fa-undo", + title: "Rotate left", + })); + } + + handleClick() { + const view = this.getMap().getView(); view.animate({ rotation: view.getRotation() - (90 * (Math.PI / 180)), duration: 100 }); - }; - - buttonLeft.addEventListener('click', handleRotateLeft, false); - - var elementLeft = document.createElement('div'); - elementLeft.className = 'rotate-left ol-unselectable ol-control ol-rotate'; - elementLeft.appendChild(buttonLeft); - - ol.control.Control.call(this, { - element: elementLeft, - target: options.target - }); + } }; /** - * @param {Object=} options Custom control options for Kitodo in OpenLayers - * @extends {ol.control.Rotate} - * @constructor + * Custom control that rotates the image 90 degrees to the right. */ -kitodo.RotateRightControl = function(options = {}) { - var buttonRight = document.createElement('button'); - buttonRight.innerHTML = ""; - buttonRight.setAttribute("type", "button"); - buttonRight.setAttribute("title", "Rotate right"); - - var this_ = this; +class RotateRightControl extends CustomControl { + + constructor(options) { + super(Object.assign(options || {}, { + className: "rotate-right", + icon: "fa-repeat", + title: "Rotate right", + })); + } - var handleRotateRight = function() { - var view = this_.getMap().getView(); + handleClick() { + const view = this.getMap().getView(); view.animate({ - rotation: view.getRotation() + (90 * (Math.PI / 180)), + rotation: view.getRotation() + (90 * (Math.PI / 180)), duration: 100 }); - }; - - buttonRight.addEventListener('click', handleRotateRight, false); - - var elementRight = document.createElement('div'); - elementRight.className = 'rotate-right ol-unselectable ol-control ol-rotate'; - elementRight.appendChild(buttonRight); - - ol.control.Control.call(this, { - element: elementRight, - target: options.target, - duration: 250 - }); + } }; -function resetNorth() { - if (kitodo.map) { - let view = kitodo.map.getView(); - view.animate({ +/** + * Custom control that rotates the image back to default. + */ +class RotateNorthControl extends CustomControl { + + constructor(options) { + super(Object.assign(options || {}, { + className: "rotate-north", + icon: "fa-compass", + title: "Reset orientation", + })); + } + + handleClick() { + this.getMap().getView().animate({ rotation: 0, - duration: 0 + duration: 100 }); } -} +}; /** - * @param {Object=} options Custom control options for Kitodo in OpenLayers - * @extends {ol.control.Rotate} - * @constructor + * Custom control that scales the image back to default. */ -kitodo.ResetNorthControl = function(options = {}) { - let buttonResetNorth = document.createElement("button"); - buttonResetNorth.innerHTML = ""; - buttonResetNorth.setAttribute("type", "button"); - buttonResetNorth.setAttribute("title", "Reset orientation"); - - buttonResetNorth.addEventListener("click", resetNorth, false); - - let elementResetNorth = document.createElement("div"); - elementResetNorth.className = "ol-rotate ol-unselectable ol-control"; /*ol-rotate-reset*/ - elementResetNorth.appendChild(buttonResetNorth); - - ol.control.Control.call(this, { - element: elementResetNorth, - target: options.target, - duration: 250 - }); +class ResetZoomControl extends CustomControl { + + /** The image dimensions as OpenLayers extent */ + #extent; + + /** + * Initialize a custom control button that scales the image back to its default position. + * + * @param {object} options containing the extent (Array) describing the image dimensions + */ + constructor(options) { + super(Object.assign(options, { + className: "reset-zoom", + icon: "fa-expand", + title: "Reset zoom", + })); + this.#extent = options.extent; + } + + handleClick() { + this.getMap().getView().fit(this.#extent, {}); + } }; -ol.inherits(kitodo.RotateLeftControl, ol.control.Rotate); -ol.inherits(kitodo.RotateRightControl, ol.control.Rotate); -ol.inherits(kitodo.ResetNorthControl, ol.control.Rotate); +/** + * Class managing OpenLayers detail map showing images. + */ +class KitodoDetailMap { + + /** + * Remember position, rotation and zoom level such that a new OpenLayers map can be + * initialized with the same position, rotation and zoom level when new images are selected. + */ + #view = { + center: null, + zoom: null, + rotation: null, + }; -function random(length) { - var text = ""; - var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + /** + * Image properties of the image that is currently shown in OpenLayers. Object with properties + * dimensions (width, height) and path (url). + */ + #image = { + dimensions: null, + path: null, + }; + + /** + * The OpenLayers maps instance + */ + #map = null; - for (var i = 0; i < length; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); + /** + * Initialize a new Kitodo detail map + */ + constructor() { + this.registerResizeEvent(); } - return text; -} + /** + * Debounces various event handlers to improve performance, e.g. when resizing. + * + * @param {function} func the function to be debounced + * @param {number} timeout the timeout in milliseconds + * + * @returns {function} the debounced function + */ + static makeDebounced(func, timeout = 100) { + let timer = null; + return function () { + clearTimeout(timer); + timer = setTimeout(func, timeout); + }; + } -function createProjection(extent) { - return new ol.proj.Projection({ - code: 'kitodo-image', - units: 'pixels', - extent: extent - }); -} + /** + * Generate a random string of [a-zA-Z0-9]. + * + * @param {number} length the length of the string to be generated + * @returns the string of random characters + */ + static randomUUID(length) { + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + let text = ""; + for (let i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return text; + } -function createSource(extent, imagePath, projection) { - return new ol.source.ImageStatic({ - url: imagePath, - projection: projection, - imageExtent: extent - }); -} + /** + * Create an OpenLayers projection given the image extent. + * + * @param {Array} extent the extent describing the image dimensions + * @returns {ol.proj.Projection} the OpenLayers projection + */ + createProjection(extent) { + return new ol.proj.Projection({ + code: 'kitodo-image', + units: 'pixels', + extent: extent + }); + } -function hideCanvas() { - let map = document.querySelector("#map canvas"); - let loadingIcon = document.querySelector("#map > .fa-spinner"); - if (map) { - map.style.opacity = 0; - loadingIcon.style.opacity = 1; + /** + * Create an OpenLayers source object for the image. + * + * @param {Array} extent the extent describing the image dimensions + * @param {string} path the path (url) of the image + * @param {ol.proj.Projection} projection the OpenLayers projection used to map the image to the canvas + * @returns {ol.source.ImageStatic} the OpenLayers image source object + */ + createSource(extent, path, projection) { + return new ol.source.ImageStatic({ + url: path, + projection: projection, + imageExtent: extent, + interpolate: true + }); } -} -function showCanvas() { - let map = document.querySelector("#map canvas"); - let loadingIcon = document.querySelector("#map > .fa-spinner"); - if (map) { - map.style.opacity = 1; - loadingIcon.style.opacity = 0; + /** + * Hide the map canvas (while the image is loading) + */ + hideCanvas() { + let canvas = document.querySelector("#map canvas"); + let loadingIcon = document.querySelector("#map > .fa-spinner"); + if (canvas) { + canvas.style.opacity = 0; + loadingIcon.style.opacity = 1; + } + } + + /** + * Show the map canvas (as soon as the image has finished loading) + */ + showCanvas() { + let canvas = document.querySelector("#map canvas"); + let loadingIcon = document.querySelector("#map > .fa-spinner"); + if (canvas) { + canvas.style.opacity = 1; + loadingIcon.style.opacity = 0; + } + } + + /** + * Handler that is called as soon as the image was completely loaded + * @param {*} image the jQuery image dom element + */ + onImageLoad(image) { + this.#image = { + dimensions: [image.width(), image.height()], + path: image[0].src, + }; + this.initializeOpenLayersMap(); } -} -function initializeMap(imageDimensions, imagePath) { - // Map image coordinates to map coordinates to be able to use image extent in pixels. - let extent = [0, 0, imageDimensions[0], imageDimensions[1]]; - let projection = createProjection(extent); - - kitodo.map = new ol.Map({ - controls: ol.control.defaults({ - attributionOptions: { - collapsible: false - }, - zoomOptions: { - delta: 3 - }, - rotate: false - }).extend([ - new kitodo.RotateRightControl(), - new kitodo.RotateLeftControl(), - new kitodo.ResetNorthControl() - ]), - layers: [ - new ol.layer.Image({ - source: createSource(extent, imagePath, projection) + /** + * Register the load event for the current image. + */ + registerImageLoadEvent() { + this.hideCanvas(); + let image = $("#imagePreviewForm\\:mediaPreviewGraphicImage"); + if (image.length > 0) { + image.on("load", this.onImageLoad.bind(this, image)); + image[0].src = image[0].src.replace(/&uuid=[a-z0-9]+/i, "") + "&uuid=" + KitodoDetailMap.randomUUID(8); + } + } + + /** + * Return extent array containg image dimensions. + * + * @param {Array} dimensions dimensions in pixel as [width, height] + * @returns {Array} the extent array + */ + createImageExtent(dimensions) { + return [0, 0, dimensions[0], dimensions[1]]; + } + + /** + * Creates the OpenLayers map object as soon as the image as been loaded. + */ + initializeOpenLayersMap() { + // Map image coordinates to map coordinates to be able to use image extent in pixels. + const extent = this.createImageExtent(this.#image.dimensions); + const projection = this.createProjection(extent); + + if (this.#map) { + // make last OpenLayers map forget canvas target + // (triggers OpenLayers cleanup code and allows garbage collection) + this.#map.setTarget(null); + } + + // initialize new OpenLayers map + this.#map = new ol.Map({ + controls: ol.control.defaults({ + attributionOptions: { + collapsible: false + }, + zoomOptions: { + delta: 3 // zoom delta when clicking zoom buttons + }, + rotate: false + }).extend([ + new RotateLeftControl(), + new RotateRightControl(), + new RotateNorthControl(), + new ResetZoomControl({ extent }) + ]), + interactions: ol.interaction.defaults({ + zoomDelta: 5, // zoom delta when using mouse wheel + zoomDuration: 100, + }), + layers: [ + new ol.layer.Image({ + source: this.createSource(extent, this.#image.path, projection) + }) + ], + target: 'map', + view: new ol.View({ + projection: projection, + center: this.unnormalizeCenter(this.#view.center, extent), + zoom: this.#view.zoom, + rotation: this.#view.rotation, + zoomFactor: 1.1, + extent, + constrainOnlyCenter: true, + smoothExtentConstraint: true, + showFullExtent: true, + padding: [20, 20, 20, 20] }) - ], - target: 'map', - view: new ol.View({ - projection: projection, - center: ol.extent.getCenter(extent), - zoomFactor: 1.1 - }) - }); - kitodo.map.getView().fit(extent, {}); - kitodo.map.on("rendercomplete", function () { - showCanvas(); - }); -} + }); + if (this.#view.center == null) { + // fit image to current viewport unless previous zoom and center position is known + this.#map.getView().fit(extent, {}); + } + // register various events to make sure that previous view is remembered + this.#map.on("rendercomplete", KitodoDetailMap.makeDebounced(this.onRenderComplete.bind(this))); + this.#map.on("change", KitodoDetailMap.makeDebounced(this.saveCurrentView.bind(this))); + this.#map.on("postrender", KitodoDetailMap.makeDebounced(this.saveCurrentView.bind(this))); + } -function updateMap(imageDimensions, imagePath) { - // Map image coordinates to map coordinates to be able to use image extent in pixels. - let extent = [0, 0, imageDimensions[0], imageDimensions[1]]; - let projection = createProjection(extent); + /** + * Return unnormalized center coordinates in case previous center is known (not null). Otherwise + * center is calculated from the image extent containing image dimensions. + * + * @param {Array} center the normalized center coordinates [0..1, 0..1] + * @returns {Array} unnormalized center + */ + unnormalizeCenter(center) { + if (center !== null) { + return [ + center[0] * this.#image.dimensions[0], + center[1] * this.#image.dimensions[1], + ]; + } + return ol.extent.getCenter(this.createImageExtent(this.#image.dimensions)); + } - kitodo.map.getLayers().getArray()[0].setSource(createSource(extent, imagePath, projection)); - kitodo.map.getView().setCenter(ol.extent.getCenter(extent)); - kitodo.map.getView().getProjection().setExtent(extent); - kitodo.map.getView().fit(extent, {}); -} + /** + * Normalizes the center coordinates from [0..width, 0..height] to [0..1, 0..1] such + * that images with different dimensions are visualized at the same relative position + * in the viewport. + * + * @param {Array} center the current center coordinates as reported by OpenLayers + * @returns {Array} the normalized center coordinates + */ + normalizeCenter(center) { + return [ + center[0] / this.#image.dimensions[0], + center[1] / this.#image.dimensions[1], + ]; + } + + /** + * Remembers current view properties (center, zoom rotation) such that the OpenLayers + * map can be initialized with the same parameters when selecting another image. + */ + saveCurrentView() { + this.#view = { + center: this.normalizeCenter(this.#map.getView().getCenter()), + zoom: this.#map.getView().getZoom(), + rotation: this.#map.getView().getRotation(), + }; + } -function addListener(element) { - element.on("load", function () { - if (kitodo.map && $("#map .ol-viewport").length) { - updateMap([element.width(), element.height()], element[0].src); - } else { - initializeMap([element.width(), element.height()], element[0].src); + /** + * Is called by OpenLayers whenever a canvas rendering has finished. Unless debounced, this + * event is triggered potentially at 60fps. + */ + onRenderComplete() { + this.showCanvas(); + this.saveCurrentView(); + } + + /** + * Registers the resize event for the meta data editor column, such that the image can be + * repositioned appropriately. + */ + registerResizeEvent() { + // reload map if container was resized + $('#thirdColumnWrapper').on('resize', KitodoDetailMap.makeDebounced(this.onResize.bind(this))); + } + + /** + * Return current zoom level. Is used by integration tests to verify zoom buttons work. + * @returns {number} current zoom level + */ + getZoom() { + if (this.#map) { + return this.#map.getView().getZoom(); } - }); -} + return -1; + } -function initializeImage() { - resetNorth(); - hideCanvas(); - let image = $("#imagePreviewForm\\:mediaPreviewGraphicImage"); - if (image.length > 0) { - addListener(image); - image[0].src = image[0].src.replace(/&uuid=[a-z0-9]+/i, "") + "&uuid=" + random(8); + /** + * Returns true if the canvas is currently animated. Is used by integration tests to wait for animation end. + * @returns {boolean} whether animation is still ongoing + */ + getAnimating() { + if (this.#map) { + return this.#map.getView().getAnimating(); + } + return false; } -} -function changeToMapView() { - initializeImage(); - showCanvas(); - if (kitodo.map) { - kitodo.map.handleTargetChanged_(); + /** + * Return current rotation. Is used by integration tests to verify rotation buttons work. + * @returns {number} current rotation + */ + getRotation() { + if (this.#map) { + return this.#map.getView().getRotation(); + } + return 0; } -} -// reload map if container was resized -$('#thirdColumnWrapper').on('resize', function () { - if (kitodo.map) { - // FIXME: This causes lags. It should only be executed *once* after resize. - kitodo.map.updateSize(); + /** + * Is called when a resize event has happened. Unless debounced, this event is triggered potentially + * at 60fps. + */ + onResize() { + if (this.#map) { + this.#map.updateSize(); + } + } + + /** + * Reloads the image. Is called when the detail view is activated, or a new image was selected. + */ + update() { + this.registerImageLoadEvent(); } -}); +} + +// register detail map class with the metadataEditor namespace +var metadataEditor = metadataEditor || {}; +metadataEditor.detailMap = new KitodoDetailMap(); -$(document).ready(function () { - initializeImage(); -}); diff --git a/Kitodo/src/main/webapp/WEB-INF/resources/js/resize.js b/Kitodo/src/main/webapp/WEB-INF/resources/js/resize.js index 208bb40dcc1..747d6129d20 100644 --- a/Kitodo/src/main/webapp/WEB-INF/resources/js/resize.js +++ b/Kitodo/src/main/webapp/WEB-INF/resources/js/resize.js @@ -397,6 +397,7 @@ function toggleThirdColumn() { secondColumn.animate({width: wrapper.width() - firstColumn.width() - COLLAPSED_COL_WIDTH - 2 * SEPARATOR_WIDTH}); } } else { + metadataEditor.detailMap.update(); var neededWidth = thirdColumnWidth - COLLAPSED_COL_WIDTH - (secondColumn.width() - secondColumn.data('min-width')); if (secondColumn.hasClass(COLLAPSED)) { firstColumn.animate({width: wrapper.width() - COLLAPSED_COL_WIDTH - thirdColumnWidth - 2 * SEPARATOR_WIDTH}); @@ -522,16 +523,14 @@ function updateMetadataEditorView(showMetadataColumn) { } expandThirdColumn(); scrollToSelectedThumbnail(); - initializeImage(); + metadataEditor.detailMap.update(); metadataEditor.gallery.mediaView.update(); scrollToSelectedTreeNode(); scrollToSelectedPaginationRow(); } function resizeMap() { - if (kitodo.map) { - kitodo.map.updateSize(); - } + metadataEditor.detailMap.onResize(); } function saveLayout() { diff --git a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/dialogs/pagination.xhtml b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/dialogs/pagination.xhtml index 3b45179b4ac..aca83ac8064 100644 --- a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/dialogs/pagination.xhtml +++ b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/dialogs/pagination.xhtml @@ -47,7 +47,7 @@ showCheckbox="true"> diff --git a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/gallery.xhtml b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/gallery.xhtml index 1cd71f79da8..7f43821e79a 100644 --- a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/gallery.xhtml +++ b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/gallery.xhtml @@ -66,7 +66,7 @@ @@ -291,8 +291,8 @@ galleryWrapperPanel"/> - - + + diff --git a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/logicalStructure.xhtml b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/logicalStructure.xhtml index fbe13c8c286..f4dc1e5fb9e 100644 --- a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/logicalStructure.xhtml +++ b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/logicalStructure.xhtml @@ -73,7 +73,7 @@ listener="#{DataEditorForm.structurePanel.treeLogicalSelect}" oncomplete="scrollToSelectedThumbnail(); scrollToSelectedPaginationRow(); - changeToMapView(); + metadataEditor.detailMap.update(); metadataEditor.gallery.mediaView.update(); expandMetadata('logical-metadata-tab');" update="galleryHeadingWrapper @@ -89,7 +89,7 @@ onstart="$('#contextMenuLogicalTree .ui-menuitem').addClass('ui-state-disabled')" oncomplete="scrollToSelectedThumbnail(); scrollToSelectedPaginationRow(); - changeToMapView(); + metadataEditor.detailMap.update(); metadataEditor.gallery.mediaView.update(); PF('contextMenuLogicalTree').show(currentEvent)" update="@(.stripe) diff --git a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/physicalStructure.xhtml b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/physicalStructure.xhtml index 5cae295802a..805262f113c 100644 --- a/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/physicalStructure.xhtml +++ b/Kitodo/src/main/webapp/WEB-INF/templates/includes/metadataEditor/physicalStructure.xhtml @@ -34,7 +34,7 @@ listener="#{DataEditorForm.structurePanel.treePhysicalSelect}" oncomplete="scrollToSelectedThumbnail(); scrollToSelectedPaginationRow(); - initializeImage(); + metadataEditor.detailMap.update(); metadataEditor.gallery.mediaView.update(); expandMetadata('physical-metadata-tab');" update="galleryHeadingWrapper @@ -44,7 +44,7 @@ div:nth-child(2) .thumbnail-container"; + private static final Double EPSILON = 0.001; + + private static int processId = -1; + + /** + * Prepare tests by inserting dummy processes into database and index for sub-folders of test metadata resources. + * @throws DAOException when saving of dummy or test processes fails. + * @throws DataException when retrieving test project for test processes fails. + * @throws IOException when copying test metadata or image files fails. + */ + @BeforeAll + public static void prepare() throws DAOException, DataException, IOException { + MockDatabase.insertFoldersForSecondProject(); + processId = MockDatabase.insertTestProcessIntoSecondProject(PROCESS_TITLE); + ProcessTestUtils.copyTestFiles(processId, TEST_RENAME_MEDIA_FILE); + } + + /** + * Tests whether the image preview is shown when a user clicks on the image preview button. + * @throws Exception when something fails + */ + @Test + public void imageVisibleTest() throws Exception { + login("kowal"); + Pages.getProcessesPage().goTo().editMetadata(PROCESS_TITLE); + + // check detail view is not yet visible + assertEquals(0, findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).size()); + + // open detail view + Pages.getMetadataEditorPage().openDetailView(); + + // check it is visible now + pollAssertTrue(() -> findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).get(0).isDisplayed()); + } + + /** + * Tests whether the zoom buttons of the image preview (zoom in, out, reset) work as intended. + * @throws Exception when something fails + */ + @Test + public void zoomLevelTest() throws Exception { + login("kowal"); + + // open detail view and wait for openlayers canvas + Pages.getProcessesPage().goTo().editMetadata(PROCESS_TITLE); + Pages.getMetadataEditorPage().openDetailView(); + pollAssertTrue(() -> findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).get(0).isDisplayed()); + + // remember initial zoom + Double initialZoom = getOpenLayersZoom(); + assertTrue(initialZoom > 0); + + // zoom in, and check zoom increases + findElementsByCSS(OPEN_LAYERS_ZOOM_IN_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> getOpenLayersZoom() > initialZoom); + + // zoom out, and check zoom returns to initial zoom level + findElementsByCSS(OPEN_LAYERS_ZOOM_OUT_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> Math.abs(getOpenLayersZoom() - initialZoom) < EPSILON); + + // zoom in, and reset zoom, check zoom returns to initial zoom level + findElementsByCSS(OPEN_LAYERS_ZOOM_IN_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + findElementsByCSS(OPEN_LAYERS_ZOOM_RESET_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> Math.abs(getOpenLayersZoom() - initialZoom) < EPSILON); + } + + /** + * Tests whether the rotation buttons of the image preview (rotation left, right, north) work as intended. + * @throws Exception when something fails + */ + @Test + public void rotationTest() throws Exception { + login("kowal"); + + // open detail view and wait for openlayers canvas + Pages.getProcessesPage().goTo().editMetadata(PROCESS_TITLE); + Pages.getMetadataEditorPage().openDetailView(); + pollAssertTrue(() -> findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).get(0).isDisplayed()); + + // check initial rotation is zero + assertTrue(Math.abs(getOpenLayersRotation()) < EPSILON); + + // rotate left and check rotation is decreasing + findElementsByCSS(OPEN_LAYERS_ROTATE_LEFT_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> getOpenLayersRotation() < 0.0); + + // rotate back, and check rotation returns to zero + findElementsByCSS(OPEN_LAYERS_ROTATE_RIGHT_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> Math.abs(getOpenLayersRotation()) < EPSILON); + + // rotate left and reset to north, check rotation returns to zero + findElementsByCSS(OPEN_LAYERS_ROTATE_LEFT_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + findElementsByCSS(OPEN_LAYERS_ROTATE_NORTH_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + assertTrue(() -> Math.abs(getOpenLayersRotation()) < EPSILON); + } + + /** + * Tests that both zoom level and rotation persists when a user clicks on another image + * (which causes OpenLayers to be loaded again). + * @throws Exception when something fails + */ + @Test + public void viewPersistsImageChange() throws Exception { + login("kowal"); + + // open detail view and wait for openlayers canvas + Pages.getProcessesPage().goTo().editMetadata(PROCESS_TITLE); + Pages.getMetadataEditorPage().openDetailView(); + pollAssertTrue(() -> findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).get(0).isDisplayed()); + + // remember initial zoom, rotation + Double initialZoom = getOpenLayersZoom(); + Double initialRotation = getOpenLayersRotation(); + + // rotate left and zoom in + findElementsByCSS(OPEN_LAYERS_ROTATE_LEFT_SELECTOR).get(0).click(); + findElementsByCSS(OPEN_LAYERS_ZOOM_IN_SELECTOR).get(0).click(); + pollAssertTrue(() -> !isOpenLayersAnimating()); + + // remember changed zoom, rotation + Double changedZoom = getOpenLayersZoom(); + Double changedRotation = getOpenLayersRotation(); + + // verify zoom and rotation was applied + assertTrue(Math.abs(initialZoom - changedZoom) > 0); + assertTrue(Math.abs(initialRotation - changedRotation) > 0); + + // change to second image + findElementsByCSS(SECOND_THUMBNAIL_SELECTOR).get(0).click(); + + // wait until second image has been loaded + pollAssertTrue( + () -> "Bild 1, Seite -".equals( + findElementsByCSS(GALLERY_HEADING_WRAPPER_SELECTOR).get(0).getText().strip() + ) + ); + + // wait until OpenLayers canvas is available + pollAssertTrue(() -> findElementsByCSS(OPEN_LAYERS_CANVAS_SELECTOR).get(0).isDisplayed()); + + // check that rotation and zoom was correctly applied to next image (and is not reset) + assertTrue(Math.abs(getOpenLayersZoom() - changedZoom) < EPSILON); + assertTrue(Math.abs(getOpenLayersRotation() - changedRotation) < EPSILON); + } + + /** + * Close metadata editor and logout after every test. + * @throws Exception when page navigation fails + */ + @AfterEach + public void closeEditorAndLogout() throws Exception { + Pages.getMetadataEditorPage().closeEditor(); + Pages.getTopNavigation().logout(); + } + + /** + * Cleanup test environment by removing temporal dummy processes from database and index. + * @throws DAOException when dummy process cannot be removed from database + * @throws CustomResponseException when dummy process cannot be removed from index + * @throws DataException when dummy process cannot be removed from index + * @throws IOException when deleting test files fails. + */ + @AfterAll + public static void cleanup() throws DAOException, CustomResponseException, DataException, IOException { + ProcessService.deleteProcess(processId); + } + + private void login(String username) throws InstantiationException, IllegalAccessException, InterruptedException { + User metadataUser = ServiceManager.getUserService().getByLogin(username); + Pages.getLoginPage().goTo().performLogin(metadataUser); + } + + private List findElementsByCSS(String css) { + return Browser.getDriver().findElements(By.cssSelector(css)); + } + + private void pollAssertTrue(Callable conditionEvaluator) throws Exception { + await().ignoreExceptions().pollInterval(100, TimeUnit.MILLISECONDS).atMost(3, TimeUnit.SECONDS) + .until(conditionEvaluator); + } + + private Boolean isOpenLayersAnimating() { + return (Boolean)Browser.getDriver().executeScript("return metadataEditor.detailMap.getAnimating()"); + } + + private Double getOpenLayersZoom() { + Object result = Browser.getDriver().executeScript("return metadataEditor.detailMap.getZoom()"); + return ((Number)result).doubleValue(); + } + + private Double getOpenLayersRotation() { + Object result = Browser.getDriver().executeScript("return metadataEditor.detailMap.getRotation()"); + return ((Number)result).doubleValue(); + } + +} diff --git a/Kitodo/src/test/java/org/kitodo/selenium/testframework/pages/MetadataEditorPage.java b/Kitodo/src/test/java/org/kitodo/selenium/testframework/pages/MetadataEditorPage.java index e6284c17a9d..131830957e6 100644 --- a/Kitodo/src/test/java/org/kitodo/selenium/testframework/pages/MetadataEditorPage.java +++ b/Kitodo/src/test/java/org/kitodo/selenium/testframework/pages/MetadataEditorPage.java @@ -78,6 +78,9 @@ public class MetadataEditorPage extends Page { @FindBy(id = "renamingMediaResultForm:okSuccess") private WebElement okButtonRenameMediaFiles; + @FindBy(id = "imagePreviewForm:previewButton") + private WebElement imagePreviewButton; + public MetadataEditorPage() { super("metadataEditor.jsf"); } @@ -222,4 +225,12 @@ public long getNumberOfDisplayedStructureElements() { return Browser.getDriver().findElements(By.cssSelector(".ui-treenode")).stream().filter(WebElement::isDisplayed) .count(); } + + /** + * Open detail view by clicking on image preview button. + */ + public void openDetailView() { + imagePreviewButton.click(); + } + } diff --git a/pom.xml b/pom.xml index 9d0be668f3d..baa5fa7c7fc 100644 --- a/pom.xml +++ b/pom.xml @@ -788,7 +788,7 @@ from system library in Java 11+ --> org.webjars openlayers - 5.2.0 + 6.14.1 org.webjars