Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Licensed to the Technische Universität Darmstadt under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The Technische Universität Darmstadt
* licenses this file to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.tudarmstadt.ukp.inception.support.wicket;

import static java.lang.String.format;
import static org.apache.wicket.markup.head.JavaScriptHeaderItem.forReference;
import static org.wicketstuff.jquery.core.Options.asString;

import org.apache.wicket.Component;
import org.apache.wicket.behavior.Behavior;
import org.apache.wicket.markup.head.IHeaderResponse;
import org.apache.wicket.markup.head.OnDomReadyHeaderItem;

/**
* Preserves the scroll position of scrollable elements across Wicket AJAX component replacements.
* <p>
* When a component is repainted via AJAX, the browser discards the scroll position of every
* scrollable element in the replaced subtree, because the replacement nodes are newly created. This
* is most visible when a repaint targets a container far above the element that actually scrolls -
* e.g. repainting the annotation page's splitter container also throws away the scroll position of
* the sidebar panels nested inside it.
* <p>
* Add this behavior once to a page and mark the elements whose scroll position should survive with
* the marker CSS class - {@value #DEFAULT_MARKER_CLASS} by default. Marked elements must have a
* stable markup id, so call {@code setOutputMarkupId(true)} on the corresponding component.
*/
public class PreserveScrollBehavior
extends Behavior
{
private static final long serialVersionUID = 1L;

public static final String DEFAULT_MARKER_CLASS = "preserve-scroll";

private final String markerClass;

public PreserveScrollBehavior()
{
this(DEFAULT_MARKER_CLASS);
}

public PreserveScrollBehavior(String aMarkerClass)
{
markerClass = aMarkerClass;
}

@Override
public void renderHead(Component aComponent, IHeaderResponse aResponse)
{
super.renderHead(aComponent, aResponse);

aResponse.render(forReference(PreserveScrollJavaScriptReference.get()));

var script = format("initInceptionPreserveScroll(%s);", asString(markerClass));

aResponse.render(OnDomReadyHeaderItem.forScript(script));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Licensed to the Technische Universität Darmstadt under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The Technische Universität Darmstadt
* licenses this file to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// When Wicket replaces a component via AJAX, the browser discards the scroll position of
// every scrollable element in the replaced subtree - the replacement nodes are freshly
// created and start at scrollTop 0. Wicket publishes '/dom/node/removing' with the old
// element and '/dom/node/added' with its replacement, and since Wicket re-resolves the
// replacement by the *same* markup id, that id is a stable key across the swap.
//
// Wicket only fires those events for the element it was actually asked to repaint, which
// is usually an ancestor (e.g. a splitter container) rather than the scrollable element
// itself. So we snapshot/restore by walking the subtree for elements carrying the marker
// class, keyed by their own markup ids.
function initInceptionPreserveScroll(markerClass) {
const STATE_KEY = 'inceptionPreserveScroll';

if (window[STATE_KEY]) {
// Already installed - a page may bind the behavior more than once (or re-render the
// component carrying it). Just record the additional marker class; the subscriptions
// below must not be registered twice or every element would be snapshotted N times.
window[STATE_KEY].markerClasses.add(markerClass);
return;
}

const state = {
markerClasses: new Set([markerClass]),
// Scroll offsets keyed by markup id, captured on '/dom/node/removing' and consumed by
// the matching '/dom/node/added'. Entries that are never consumed (the element was
// removed outright rather than replaced) are cleared at the end of the request.
offsets: new Map()
};
window[STATE_KEY] = state;

const selector = () => Array.from(state.markerClasses)
.map(cls => '.' + cls)
.join(',');

// The marker may sit on the replaced element itself or anywhere beneath it.
const scrollablesIn = (element) => {
if (!element || !element.querySelectorAll) return [];
const sel = selector();
const found = Array.from(element.querySelectorAll(sel));
if (element.matches && element.matches(sel)) found.unshift(element);
return found;
};

Wicket.Event.subscribe(Wicket.Event.Topic.DOM_NODE_REMOVING, (jqEvent, element) => {
scrollablesIn(element).forEach(scrollable => {
// Elements without an id cannot be matched up again after the swap.
if (!scrollable.id) return;
if (!scrollable.scrollTop && !scrollable.scrollLeft) return;
state.offsets.set(scrollable.id, {
top: scrollable.scrollTop,
left: scrollable.scrollLeft
});
});
});

Wicket.Event.subscribe(Wicket.Event.Topic.DOM_NODE_ADDED, (jqEvent, element) => {
scrollablesIn(element).forEach(scrollable => {
const offset = state.offsets.get(scrollable.id);
if (!offset) return;
state.offsets.delete(scrollable.id);

// Restoring immediately would clamp against a scrollHeight that has not settled yet
// if the new content lays out asynchronously (images, fonts, lazily filled lists),
// leaving the element short of where the user was. Applying it now covers the common
// synchronous case without a visible jump, and again after layout covers the rest.
const apply = () => {
scrollable.scrollTop = offset.top;
scrollable.scrollLeft = offset.left;
};
apply();
requestAnimationFrame(apply);
});
});

// A replaced element is not guaranteed to come back (it may have been removed, or moved
// out of the repainted subtree). Dropping leftovers keeps a stale offset from being
// applied to some unrelated later render that happens to reuse the id.
Wicket.Event.subscribe(Wicket.Event.Topic.AJAX_CALL_COMPLETE, () => {
requestAnimationFrame(() => state.offsets.clear());
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Licensed to the Technische Universität Darmstadt under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The Technische Universität Darmstadt
* licenses this file to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License.
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.tudarmstadt.ukp.inception.support.wicket;

import org.apache.wicket.request.resource.JavaScriptResourceReference;

public class PreserveScrollJavaScriptReference
extends JavaScriptResourceReference
{
private static final long serialVersionUID = 1L;

private static final PreserveScrollJavaScriptReference INSTANCE = new PreserveScrollJavaScriptReference();

public static PreserveScrollJavaScriptReference get()
{
return INSTANCE;
}

private PreserveScrollJavaScriptReference()
{
super(PreserveScrollJavaScriptReference.class, "PreserveScrollBehavior.js");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
import de.tudarmstadt.ukp.clarin.webanno.ui.core.footer.FooterItemRegistry;
import de.tudarmstadt.ukp.inception.bootstrap.BootstrapFeedbackPanel;
import de.tudarmstadt.ukp.inception.support.interceptors.GlobalInterceptorsRegistry;
import de.tudarmstadt.ukp.inception.support.wicket.PreserveScrollBehavior;
import de.tudarmstadt.ukp.inception.ui.core.darkmode.DarkModeWrapper;
import jakarta.servlet.http.HttpServletRequest;

Expand Down Expand Up @@ -99,6 +100,8 @@ private void commonInit()
interceptor.intercept(this);
}

add(new PreserveScrollBehavior());

add(body = new DarkModeWrapper("body"));

footerItems = new ListModel<>(new ArrayList<>());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
</div>
</div>

<div class="scrolling flex-content" wicket:id="resultsTable">
<div class="scrolling flex-content preserve-scroll" wicket:id="resultsTable">
<table class="table table-striped table-sm">
<tbody wicket:id="resultsGroupContainer">
<wicket:container wicket:id="searchResultGroups">
Expand Down
Loading