diff --git a/src/main_python.ts b/src/main_python.ts index 8357627dd5..cae3cd0418 100644 --- a/src/main_python.ts +++ b/src/main_python.ts @@ -147,12 +147,7 @@ configState.add("volumeRequests", volumeHandler.requestState); let sharedState: Trackable | undefined = viewer.state; if (window.location.hash) { - const hashBinding = viewer.registerDisposer( - new UrlHashBinding( - viewer.state, - viewer.dataSourceProvider.sharedKvStoreContext, - ), - ); + const hashBinding = viewer.registerDisposer(new UrlHashBinding(viewer)); hashBinding.updateFromUrlHash(); sharedState = undefined; } diff --git a/src/ui/default_viewer_setup.ts b/src/ui/default_viewer_setup.ts index fc6bb1a406..6d1d87c323 100644 --- a/src/ui/default_viewer_setup.ts +++ b/src/ui/default_viewer_setup.ts @@ -35,16 +35,12 @@ export function setupDefaultViewer(options?: Partial) { setDefaultInputEventBindings(viewer.inputEventBindings); const hashBinding = viewer.registerDisposer( - new UrlHashBinding( - viewer.state, - viewer.dataSourceProvider.sharedKvStoreContext, - { - defaultFragment: - typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined" - ? NEUROGLANCER_DEFAULT_STATE_FRAGMENT - : undefined, - }, - ), + new UrlHashBinding(viewer, { + defaultFragment: + typeof NEUROGLANCER_DEFAULT_STATE_FRAGMENT !== "undefined" + ? NEUROGLANCER_DEFAULT_STATE_FRAGMENT + : undefined, + }), ); viewer.registerDisposer( hashBinding.parseError.changed.add(() => { diff --git a/src/ui/url_hash_binding.ts b/src/ui/url_hash_binding.ts index 29438cd483..a0fdedcd89 100644 --- a/src/ui/url_hash_binding.ts +++ b/src/ui/url_hash_binding.ts @@ -14,18 +14,17 @@ * limitations under the License. */ -import { debounce } from "lodash-es"; -import type { SharedKvStoreContext } from "#src/kvstore/frontend.js"; import { StatusMessage } from "#src/status.js"; import { WatchableValue } from "#src/trackable_value.js"; +import { dynamicDebounce } from "#src/util/debounce.js"; import { RefCounted } from "#src/util/disposable.js"; import { bigintToStringJsonReplacer, urlSafeParse, verifyObject, } from "#src/util/json.js"; -import type { Trackable } from "#src/util/trackable.js"; import { getCachedJson } from "#src/util/trackable.js"; +import type { Viewer } from "#src/viewer.js"; /** * @file Implements a binding between a Trackable value and the URL hash state. @@ -68,23 +67,47 @@ export class UrlHashBinding extends RefCounted { private defaultFragment: string; + get root() { + return this.viewer.state; + } + + get sharedKvStoreContext() { + return this.viewer.dataSourceProvider.sharedKvStoreContext; + } + constructor( - public root: Trackable, - public sharedKvStoreContext: SharedKvStoreContext, + private viewer: Viewer, options: UrlHashBindingOptions = {}, ) { super(); - const { updateDelayMilliseconds = 200, defaultFragment = "{}" } = options; + const { defaultFragment = "{}" } = options; + const { root } = this; this.registerEventListener(window, "hashchange", () => this.updateFromUrlHash(), ); - const throttledSetUrlHash = debounce( - () => this.setUrlHash(), - updateDelayMilliseconds, - { maxWait: updateDelayMilliseconds * 2 }, + const throttledSetUrlHash = this.registerDisposer( + dynamicDebounce( + () => this.setUrlHash(), + viewer.urlHashRateLimit, + (wait) => ({ maxWait: wait * 2 }), + ), ); this.registerDisposer(root.changed.add(throttledSetUrlHash)); - this.registerDisposer(() => throttledSetUrlHash.cancel()); + // try to update the url before the user might attempt to be copying it + window.addEventListener("blur", () => { + throttledSetUrlHash.flush(); + }); + // mouseleave works better (occurs earlier) than blur + document.addEventListener("mouseleave", () => { + throttledSetUrlHash.flush(); + }); + // update url for the select url shortcut (ctrl+l/cmd+l) + // select url triggers the blur event, but for chrome, it occurs too late for the url to be updated + window.addEventListener("keydown", (event) => { + if (event.key === "l") { + throttledSetUrlHash.flush(); + } + }); this.defaultFragment = defaultFragment; } diff --git a/src/ui/viewer_settings.ts b/src/ui/viewer_settings.ts index 913cb39749..a8f2b43178 100644 --- a/src/ui/viewer_settings.ts +++ b/src/ui/viewer_settings.ts @@ -102,6 +102,7 @@ export class ViewerSettingsPanel extends SidePanel { "Concurrent chunk requests", viewer.chunkQueueManager.capacities.download.itemLimit, ); + addLimitWidget("Url update rate limit (ms)", viewer.urlHashRateLimit); const addCheckbox = ( label: string, diff --git a/src/util/debounce.ts b/src/util/debounce.ts new file mode 100644 index 0000000000..80a2a63994 --- /dev/null +++ b/src/util/debounce.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * 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. + */ + +import type { DebouncedFunc, DebounceSettings } from "lodash-es"; +import { debounce } from "lodash-es"; +import type { WatchableValue } from "#src/trackable_value.js"; + +export function dynamicDebounce any>( + func: T, + wait: WatchableValue, + options?: (wait: number) => DebounceSettings, +) { + let debouncedFunc: DebouncedFunc | undefined = undefined; + const updateDebounce = () => { + debouncedFunc?.flush(); // or cancel + debouncedFunc = debounce(func, wait.value, options?.(wait.value)); + }; + const unregister = wait.changed.add(updateDebounce); + updateDebounce(); + return Object.assign( + (...args: Parameters) => { + return debouncedFunc!(...args); + }, + { + cancel: () => { + debouncedFunc?.cancel(); + }, + dispose: () => { + debouncedFunc?.cancel(); + unregister(); + }, + flush: () => { + debouncedFunc?.flush(); + }, + }, + ); +} diff --git a/src/viewer.ts b/src/viewer.ts index 4ade91a500..6b25acecc9 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -115,6 +115,7 @@ import { vec3 } from "#src/util/geom.js"; import { parseFixedLengthArray, verifyFinitePositiveFloat, + verifyNonnegativeInt, verifyObject, verifyOptionalObjectProperty, verifyString, @@ -248,6 +249,7 @@ class TrackableViewerState extends CompoundTrackable { this.add("projectionScale", viewer.projectionScale); this.add("projectionDepth", viewer.projectionDepthRange); this.add("layers", viewer.layerSpecification); + this.add("urlHashRateLimit", viewer.urlHashRateLimit); this.add("showAxisLines", viewer.showAxisLines); this.add("wireFrame", viewer.wireFrame); this.add("enableAdaptiveDownsampling", viewer.enableAdaptiveDownsampling); @@ -422,6 +424,7 @@ export class Viewer extends RefCounted implements ViewerState { selectedLayer = this.registerDisposer( new SelectedLayerState(this.layerManager.addRef()), ); + urlHashRateLimit = new TrackableValue(200, verifyNonnegativeInt); showAxisLines = new TrackableBoolean(true, true); wireFrame = new TrackableBoolean(false, false); enableAdaptiveDownsampling = new TrackableBoolean(true, true);