From 12666548360042880918d70c7e4f287dfec10e81 Mon Sep 17 00:00:00 2001 From: Chris Jordan Date: Tue, 22 Oct 2024 00:30:07 -0400 Subject: [PATCH] feat(datasource/graphene) added timestamp tool to graphene, timestamp property to SegmentationUserLayer --- src/datasource/graphene/frontend.ts | 262 ++++++++++++++++++---- src/layer/segmentation/index.ts | 39 ++++ src/layer/segmentation/json_keys.ts | 2 + src/segmentation_display_state/backend.ts | 4 +- src/segmentation_display_state/base.ts | 2 + src/ui/layer_bar.ts | 60 +++++ src/widget/datetime.ts | 82 +++++++ src/widget/icon.css | 4 + 8 files changed, 414 insertions(+), 41 deletions(-) create mode 100644 src/widget/datetime.ts diff --git a/src/datasource/graphene/frontend.ts b/src/datasource/graphene/frontend.ts index 0cf54a809..24f90734d 100644 --- a/src/datasource/graphene/frontend.ts +++ b/src/datasource/graphene/frontend.ts @@ -205,9 +205,15 @@ import { } from "#src/util/special_protocol_request.js"; import type { Trackable } from "#src/util/trackable.js"; import { Uint64 } from "#src/util/uint64.js"; +import { DateTimeInputWidget } from "#src/widget/datetime.js"; import { makeDeleteButton } from "#src/widget/delete_button.js"; import type { DependentViewContext } from "#src/widget/dependent_view_widget.js"; import { makeIcon } from "#src/widget/icon.js"; +import type { LayerControlFactory } from "#src/widget/layer_control.js"; +import { + addLayerControlToOptionsTab, + registerLayerControl, +} from "#src/widget/layer_control.js"; function vec4FromVec3(vec: vec3, alpha = 0) { const res = vec4.clone([...vec]); @@ -1335,8 +1341,30 @@ class GraphConnection extends SegmentationGraphSourceConnection { const { annotationLayerStates, - state: { multicutState, findPathState }, + state: { multicutState, mergeState, findPathState }, } = this; + + const { timestamp } = segmentsState; + this.registerDisposer( + timestamp.changed.add(async () => { + const nonLatestRoots = await this.graph.graphServer.filterLatestRoots( + [...segmentsState.selectedSegments], + timestamp.value, + true, + ); + segmentsState.selectedSegments.delete(nonLatestRoots); + const unsetTimestamp = timestamp.value === undefined; + if (unsetTimestamp) { + const { + focusSegment: { value: focusSegment }, + } = state.multicutState; + if (focusSegment) { + segmentsState.visibleSegments.add(focusSegment); + } + } + }), + ); + const loadedSubsource = getGraphLoadedSubsource(layer)!; const redGroup = makeColoredAnnotationState( layer, @@ -1524,6 +1552,22 @@ class GraphConnection extends SegmentationGraphSourceConnection { }), ); findPathChanged(); // initial state + this.registerDisposer( + state.changed.add(() => { + if (segmentsState.timestamp.value === undefined) { + if ( + multicutState.focusSegment.value || + mergeState.merges.value.length > 0 + ) { + // remind me why want to add ourselves compared to keeping it empty + // if it is non empty, graphene knows there is a tool locking it + segmentsState.timestampOwner.add(layer.managedLayer.name); + } else { + segmentsState.timestampOwner.delete(layer.managedLayer.name); + } + } + }), + ); } createRenderLayers( @@ -1549,10 +1593,17 @@ class GraphConnection extends SegmentationGraphSourceConnection { private visibleSegmentsChanged(segments: Uint64[] | null, added: boolean) { const { segmentsState } = this; + const { state } = this.graph; const { focusSegment: { value: focusSegment }, - } = this.graph.state.multicutState; - if (focusSegment && !segmentsState.visibleSegments.has(focusSegment)) { + } = state.multicutState; + const { timestamp } = segmentsState; + const unsetTimestamp = timestamp.value === undefined; + if ( + unsetTimestamp && + focusSegment && + !segmentsState.visibleSegments.has(focusSegment) + ) { if (segmentsState.selectedSegments.has(focusSegment)) { StatusMessage.showTemporaryMessage( `Can't hide active multicut segment.`, @@ -1564,7 +1615,6 @@ class GraphConnection extends SegmentationGraphSourceConnection { 3000, ); } - segmentsState.selectedSegments.add(focusSegment); segmentsState.visibleSegments.add(focusSegment); if (segments) { segments = segments.filter( @@ -1625,13 +1675,15 @@ class GraphConnection extends SegmentationGraphSourceConnection { ); const segmentConst = segmentId.clone(); if (added && isBaseSegment) { - this.graph.getRoot(segmentConst).then((rootId) => { - if (segmentsState.visibleSegments.has(segmentConst)) { - segmentsState.visibleSegments.add(rootId); - } - segmentsState.selectedSegments.delete(segmentConst); - segmentsState.selectedSegments.add(rootId); - }); + this.graph + .getRoot(segmentConst, segmentsState.timestamp.value) + .then((rootId) => { + if (segmentsState.visibleSegments.has(segmentConst)) { + segmentsState.visibleSegments.add(rootId); + } + segmentsState.selectedSegments.delete(segmentConst); + segmentsState.selectedSegments.add(rootId); + }); } } } @@ -1945,11 +1997,22 @@ class GrapheneGraphServerInterface { private credentialsProvider: SpecialProtocolCredentialsProvider, ) {} - async getRoot(segment: Uint64, timestamp = "") { - const timestampEpoch = new Date(timestamp).valueOf() / 1000; + async getTimestampLimit() { + const response = await cancellableFetchSpecialOk( + this.credentialsProvider, + `${this.url}/oldest_timestamp`, + {}, + responseJson, + ); + const isoString = verifyObjectProperty(response, "iso", verifyString); + return new Date(isoString).valueOf(); + } + + async getRoot(segment: Uint64, timestamp = 0) { + const timestampEpoch = timestamp / 1000; const url = `${this.url}/node/${String(segment)}/root?int64_as_str=1${ - Number.isNaN(timestampEpoch) ? "" : `×tamp=${timestampEpoch}` + timestamp > 0 ? `×tamp=${timestampEpoch}` : "" }`; const promise = cancellableFetchSpecialOk( @@ -2036,9 +2099,15 @@ class GrapheneGraphServerInterface { return final; } - async filterLatestRoots(segments: Uint64[]): Promise { - const url = `${this.url}/is_latest_roots`; - + async filterLatestRoots( + segments: Uint64[], + timestamp = 0, + flipResult = false, + ): Promise { + const timestampEpoch = timestamp / 1000; + const url = `${this.url}/is_latest_roots${ + timestamp > 0 ? `?timestamp=${timestampEpoch}` : "" + }`; const promise = cancellableFetchSpecialOk( this.credentialsProvider, url, @@ -2048,15 +2117,13 @@ class GrapheneGraphServerInterface { }, responseIdentity, ); - const response = await withErrorMessageHTTP(promise, { errorPrefix: `Could not check latest: `, }); const jsonResp = await response.json(); - const res: Uint64[] = []; for (const [i, isLatest] of jsonResp["is_latest"].entries()) { - if (isLatest) { + if (isLatest !== flipResult) { res.push(segments[i]); } } @@ -2118,9 +2185,9 @@ class GrapheneGraphServerInterface { } class GrapheneGraphSource extends SegmentationGraphSource { - private connections = new Set(); public graphServer: GrapheneGraphServerInterface; private l2CacheAvailable: boolean | undefined = undefined; + public timestampLimit = new TrackableValue(0, (x) => x); constructor( public info: GrapheneMultiscaleVolumeInfo, @@ -2133,24 +2200,15 @@ class GrapheneGraphSource extends SegmentationGraphSource { info.app!.segmentationUrl, credentialsProvider, ); + this.graphServer.getTimestampLimit().then((limit) => { + this.timestampLimit.value = limit; + }); } connect( layer: SegmentationUserLayer, ): Owned { - const connection = new GraphConnection( - this, - layer, - this.chunkSource, - this.state, - ); - - this.connections.add(connection); - connection.registerDisposer(() => { - this.connections.delete(connection); - }); - - return connection; + return new GraphConnection(this, layer, this.chunkSource, this.state); } get visibleSegmentEquivalencePolicy() { @@ -2180,8 +2238,8 @@ class GrapheneGraphSource extends SegmentationGraphSource { } } - getRoot(segment: Uint64) { - return this.graphServer.getRoot(segment); + getRoot(segment: Uint64, timestamp?: number) { + return this.graphServer.getRoot(segment, timestamp); } async findPath( @@ -2244,6 +2302,9 @@ class GrapheneGraphSource extends SegmentationGraphSource { parent.style.display = "contents"; const toolbox = document.createElement("div"); toolbox.className = "neuroglancer-segmentation-toolbox"; + parent.appendChild( + addLayerControlToOptionsTab(tab, layer, tab.visibility, timeControl), + ); toolbox.appendChild( makeToolButton(context, layer.toolBinder, { toolJson: GRAPHENE_MULTICUT_SEGMENTS_TOOL_ID, @@ -2581,6 +2642,122 @@ const getPoint = ( return undefined; }; +const GRAPHENE_TIME_JSON_KEY = "grapheneTime"; + +const timeControl = { + label: "Time", + title: "View segmentation at earlier point of time", + toolJson: GRAPHENE_TIME_JSON_KEY, + ...timeLayerControl(), +}; + +registerLayerControl(SegmentationUserLayer, timeControl); + +function timeLayerControl(): LayerControlFactory { + return { + makeControl: (layer, context) => { + const segmentationGroupState = + layer.displayState.segmentationGroupState.value; + const { + graph: { value: graph }, + } = segmentationGroupState; + const timestamp = + graph instanceof GrapheneGraphSource + ? segmentationGroupState.timestamp + : new WatchableValue(undefined); + const timestampLimit = + graph instanceof GrapheneGraphSource + ? graph.timestampLimit + : new WatchableValue(0); + const timestampOwner = + graph instanceof GrapheneGraphSource + ? segmentationGroupState.timestampOwner + : new WatchableSet(); + + const controlElement = document.createElement("div"); + controlElement.classList.add("neuroglancer-time-control"); + const intermediateTimestamp = new WatchableValue( + timestamp.value, + ); + intermediateTimestamp.changed.add(async () => { + if (intermediateTimestamp.value === timestamp.value) { + return; + } + // resetting timestamp back to unset + if ( + intermediateTimestamp.value === undefined && + segmentationGroupState.canSetTimestamp(layer.managedLayer.name) + ) { + timestamp.value = intermediateTimestamp.value; + timestampOwner.delete(layer.managedLayer.name); + return; + } + if (graph instanceof GrapheneGraphSource) { + const selfLock = segmentationGroupState.timestampOwner.has( + layer.managedLayer.name, + ); + const canSetTimestamp = segmentationGroupState.canSetTimestamp( + layer.managedLayer.name, + ); + // if we have a lock while the timestamp is unset, it is a tool-based lock (this check can be improved) + if (canSetTimestamp && (!selfLock || timestamp.value !== undefined)) { + const nonLatestRoots = await graph.graphServer.filterLatestRoots( + [...segmentationGroupState.selectedSegments], + timestamp.value, + true, + ); + if ( + !nonLatestRoots.length || + confirm( + `Changing graphene time will clear ${nonLatestRoots.length} segment(s).`, + ) + ) { + timestamp.value = intermediateTimestamp.value; + // is this where it is done + timestampOwner.add(layer.managedLayer.name); + return; + } + } + intermediateTimestamp.value = timestamp.value; + StatusMessage.showTemporaryMessage("Timestamp is locked."); + } + }); + const widget = context.registerDisposer( + new DateTimeInputWidget( + intermediateTimestamp, + new Date(timestampLimit.value), + new Date(), + ), + ); + timestampLimit.changed.add(() => { + widget.setMin(new Date(timestampLimit.value)); + }); + timestamp.changed.add(() => { + if (timestamp.value !== intermediateTimestamp.value) { + intermediateTimestamp.value = timestamp.value; + } + }); + controlElement.appendChild(widget.element); + return { controlElement, control: widget }; + }, + activateTool: (_activation) => {}, + }; +} + +const checkSegmentationOld = ( + timestamp: WatchableValue, + activation: ToolActivation, +) => { + if (timestamp.value !== undefined) { + StatusMessage.showTemporaryMessage( + "Editing can not be performed with a segmentation at an older state.", + ); + activation.cancel(); + return true; + } + return false; +}; + const MULTICUT_SEGMENTS_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift?+control+mousedown0": { action: "set-anchor" }, "at:shift?+keyg": { action: "swap-group" }, @@ -2904,17 +3081,22 @@ class MergeSegmentsTool extends LayerTool { graphConnection: { value: graphConnection }, tool, } = this.layer; - if (!graphConnection || !(graphConnection instanceof GraphConnection)) + if (!graphConnection || !(graphConnection instanceof GraphConnection)) { + activation.cancel(); return; + } const { state: { mergeState }, + segmentsState: { timestamp }, + mergeAnnotationState, } = graphConnection; - if (mergeState === undefined) return; + if (checkSegmentationOld(timestamp, activation)) { + return; + } const { merges, autoSubmit } = mergeState; - const lineTool = new MergeSegmentsPlaceLineTool( this.layer, - graphConnection.mergeAnnotationState, + mergeAnnotationState, ); tool.value = lineTool; activation.registerDisposer(() => { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 8a5981d87..df2b86f6b 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -97,6 +97,7 @@ import { makeCachedLazyDerivedWatchableValue, registerNestedSync, TrackableValue, + WatchableSet, WatchableValue, } from "#src/trackable_value.js"; import { UserLayerWithAnnotationsMixin } from "#src/ui/annotations.js"; @@ -124,6 +125,7 @@ import { verifyObjectAsMap, verifyOptionalObjectProperty, verifyString, + verifyStringArray, } from "#src/util/json.js"; import { Signal } from "#src/util/signal.js"; import { Uint64 } from "#src/util/uint64.js"; @@ -161,6 +163,9 @@ export class SegmentationUserLayerGroupState } } }); + + this.timestamp.changed.add(specificationChanged.dispatch); + this.timestampOwner.changed.add(specificationChanged.dispatch); } restoreState(specification: unknown) { @@ -202,6 +207,24 @@ export class SegmentationUserLayerGroupState json_keys.SEGMENT_QUERY_JSON_KEY, (value) => this.segmentQuery.restoreState(value), ); + verifyOptionalObjectProperty( + specification, + json_keys.TIMESTAMP_OWNER_JSON_KEY, + (value) => { + const owners = verifyStringArray(value); + this.timestampOwner.clear(); + for (const owner of owners) { + this.timestampOwner.add(owner); + } + }, + ); + verifyOptionalObjectProperty( + specification, + json_keys.TIMESTAMP_JSON_KEY, + (value) => { + this.timestamp.restoreState(value); + }, + ); } toJSON() { @@ -223,6 +246,10 @@ export class SegmentationUserLayerGroupState x[json_keys.EQUIVALENCES_JSON_KEY] = segmentEquivalences.toJSON(); } x[json_keys.SEGMENT_QUERY_JSON_KEY] = this.segmentQuery.toJSON(); + x[json_keys.TIMESTAMP_JSON_KEY] = this.timestamp.toJSON(); + if (this.timestampOwner.size > 0) { + x[json_keys.TIMESTAMP_OWNER_JSON_KEY] = [...this.timestampOwner]; + } return x; } @@ -232,9 +259,13 @@ export class SegmentationUserLayerGroupState this.selectedSegments.assignFrom(other.selectedSegments); this.visibleSegments.assignFrom(other.visibleSegments); this.segmentEquivalences.assignFrom(other.segmentEquivalences); + this.timestamp.value = other.timestamp.value; + this.timestampOwner.values = new Set(other.timestampOwner); // TODO this won't trigger changed properly } localGraph = new LocalSegmentationGraphSource(); + timestamp = new TrackableValue(undefined, (x) => x); + timestampOwner = new WatchableSet(); visibleSegments = this.registerDisposer( Uint64Set.makeWithCounterpart(this.layer.manager.rpc), ); @@ -277,6 +308,14 @@ export class SegmentationUserLayerGroupState useTemporarySegmentEquivalences = this.layer.registerDisposer( SharedWatchableValue.make(this.layer.manager.rpc, false), ); + + canSetTimestamp(owner?: string) { + const otherOwners = [...this.timestampOwner].filter((x) => x !== owner); + if (otherOwners.length) { + return false; + } + return true; + } } export class SegmentationUserLayerColorGroupState diff --git a/src/layer/segmentation/json_keys.ts b/src/layer/segmentation/json_keys.ts index 96417a61c..e942d6357 100644 --- a/src/layer/segmentation/json_keys.ts +++ b/src/layer/segmentation/json_keys.ts @@ -25,3 +25,5 @@ export const SEGMENT_DEFAULT_COLOR_JSON_KEY = "segmentDefaultColor"; export const ANCHOR_SEGMENT_JSON_KEY = "anchorSegment"; export const SKELETON_RENDERING_SHADER_CONTROL_TOOL_ID = "skeletonShaderControl"; +export const TIMESTAMP_JSON_KEY = "timestamp"; +export const TIMESTAMP_OWNER_JSON_KEY = "timestampOwner"; diff --git a/src/segmentation_display_state/backend.ts b/src/segmentation_display_state/backend.ts index b3f99edd9..7ce1348af 100644 --- a/src/segmentation_display_state/backend.ts +++ b/src/segmentation_display_state/backend.ts @@ -27,12 +27,13 @@ import type { VisibleSegmentsState, } from "#src/segmentation_display_state/base.js"; import { + VISIBLE_SEGMENTS_STATE_PROPERTIES, onTemporaryVisibleSegmentsStateChanged, onVisibleSegmentsStateChanged, - VISIBLE_SEGMENTS_STATE_PROPERTIES, } from "#src/segmentation_display_state/base.js"; import type { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { WatchableValue } from "#src/trackable_value.js"; import type { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import type { Uint64Set } from "#src/uint64_set.js"; import type { AnyConstructor } from "#src/util/mixin.js"; @@ -57,6 +58,7 @@ export const withSegmentationLayerBackendState = < Base: TBase, ) => class SegmentationLayerState extends Base implements VisibleSegmentsState { + timestamp: WatchableValue; visibleSegments: Uint64Set; selectedSegments: Uint64OrderedSet; segmentEquivalences: SharedDisjointUint64Sets; diff --git a/src/segmentation_display_state/base.ts b/src/segmentation_display_state/base.ts index 6b0971653..492b8da60 100644 --- a/src/segmentation_display_state/base.ts +++ b/src/segmentation_display_state/base.ts @@ -17,12 +17,14 @@ import { VisibleSegmentEquivalencePolicy } from "#src/segmentation_graph/segment_id.js"; import type { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import type { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { WatchableValue } from "#src/trackable_value.js"; import type { Uint64OrderedSet } from "#src/uint64_ordered_set.js"; import type { Uint64Set } from "#src/uint64_set.js"; import type { RefCounted } from "#src/util/disposable.js"; import type { Uint64 } from "#src/util/uint64.js"; export interface VisibleSegmentsState { + timestamp: WatchableValue; visibleSegments: Uint64Set; selectedSegments: Uint64OrderedSet; segmentEquivalences: SharedDisjointUint64Sets; diff --git a/src/ui/layer_bar.ts b/src/ui/layer_bar.ts index cb6eadeea..ac09e76e4 100644 --- a/src/ui/layer_bar.ts +++ b/src/ui/layer_bar.ts @@ -19,8 +19,10 @@ import "#src/ui/layer_bar.css"; import svg_plus from "ikonate/icons/plus.svg?raw"; import type { ManagedUserLayer } from "#src/layer/index.js"; import { addNewLayer, deleteLayer, makeLayer } from "#src/layer/index.js"; +import { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import type { LayerGroupViewer } from "#src/layer_group_viewer.js"; import { NavigationLinkType } from "#src/navigation_state.js"; +import { StatusMessage } from "#src/status.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import type { DropLayers } from "#src/ui/layer_drag_and_drop.js"; import { @@ -67,6 +69,64 @@ class LayerWidget extends RefCounted { element.appendChild(prefetchProgress); labelElement.className = "neuroglancer-layer-item-label"; labelElement.appendChild(labelElementText); + + this.registerDisposer( + layer.readyStateChanged.add(() => { + if (layer.isReady() && layer.layer instanceof SegmentationUserLayer) { + const timeButton = makeIcon({ + text: "🕘", + }); + element.appendChild(timeButton); + const { segmentationGroupState } = layer.layer.displayState; + const { timestamp, timestampOwner } = segmentationGroupState.value; + const updateTimeButton = () => { + const otherOwners = [...timestampOwner].filter( + (x) => x !== layer.name, + ); + if (timestamp.value) { + if (otherOwners.length) { + timeButton.title = `${new Date(timestamp.value)}.\nBound to layer(s): ${otherOwners.join(", ")}`; + } else { + timeButton.title = `${new Date(timestamp.value)}.\nClick to return to current form.`; + } + timeButton.classList.toggle("locked", otherOwners.length > 0); + } + timeButton.style.display = + timestamp.value === undefined ? "none" : "inherit"; + }; + updateTimeButton(); + timestampOwner.changed.add(() => { + updateTimeButton(); + const layerNames = layer.manager.layerManager.managedLayers.map( + (x) => x.name, + ); + const invalidOwners = [...timestampOwner].filter( + (x) => !layerNames.includes(x), + ); + for (const owner of invalidOwners) { + timestampOwner.delete(owner); + } + }); + timestamp.changed.add(() => { + updateTimeButton(); + }); + timeButton.addEventListener("click", (evt) => { + evt.stopPropagation(); + if (segmentationGroupState.value.canSetTimestamp(layer.name)) { + timestamp.reset(); + timestampOwner.clear(); // TODO(chrisj) should we reset timestamp owner or should that be layer controlled? + } else { + const otherOwners = [...timestampOwner].filter( + (x) => x !== layer.name, + ); + StatusMessage.showTemporaryMessage( + `Segmentation time bound to layer(s): ${otherOwners.join(", ")}`, + ); + } + }); + } + }), + ); visibleProgress.className = "neuroglancer-layer-item-visible-progress"; prefetchProgress.className = "neuroglancer-layer-item-prefetch-progress"; layerNumberElement.className = "neuroglancer-layer-item-number"; diff --git a/src/widget/datetime.ts b/src/widget/datetime.ts new file mode 100644 index 000000000..edef06a33 --- /dev/null +++ b/src/widget/datetime.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2020 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 { WatchableValue } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { removeFromParent } from "#src/util/dom.js"; + +function toDateTimeLocalString(date: Date) { + return new Date(date.getTime() - date.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, -8); +} + +export class DateTimeInputWidget extends RefCounted { + element = document.createElement("input"); + constructor( + public model: WatchableValue, + minDate?: Date, + maxDate?: Date, + ) { + super(); + this.registerDisposer(model.changed.add(() => this.updateView())); + const { element } = this; + element.type = "datetime-local"; + if (minDate) { + this.setMin(minDate); + } + if (maxDate) { + this.setMax(maxDate); + } + this.registerEventListener(element, "change", () => this.updateModel()); + this.updateView(); + } + + setMin(date: Date) { + const { element } = this; + element.min = toDateTimeLocalString(date); + } + + setMax(date: Date) { + const { element } = this; + element.max = toDateTimeLocalString(date); + } + + disposed() { + removeFromParent(this.element); + } + + private updateView() { + if (this.model.value !== undefined) { + this.element.value = toDateTimeLocalString(new Date(this.model.value)); + } else { + this.element.value = ""; + } + } + + private updateModel() { + try { + if (this.element.value) { + this.model.value = new Date(this.element.value).valueOf(); + } else { + this.model.value = undefined; + } + } catch { + // Ignore invalid input. + } + this.updateView(); + } +} diff --git a/src/widget/icon.css b/src/widget/icon.css index 0e82a0a6b..3cfb91c8f 100644 --- a/src/widget/icon.css +++ b/src/widget/icon.css @@ -48,6 +48,10 @@ background-color: #db4437; } +.neuroglancer-icon.locked { + background-color: yellow; +} + .neuroglancer-icon-hover:not(:hover) svg:last-child { display: none; }