mirror of
https://github.com/enso-org/enso.git
synced 2024-11-05 03:59:38 +03:00
Generalize keyed local storage for more client side graph-editor data (#9990)
Fixes #9938 The documentation panel openness and size state are saved in localStorage. On initial graph entry, the documentation panel is automatically opened if the graph's function is documented.
This commit is contained in:
parent
f33aead7ef
commit
ca916b823e
@ -8,10 +8,13 @@ import { graphNodeByBinding } from './locate'
|
||||
// =================
|
||||
|
||||
/** Perform a successful login. */
|
||||
export async function goToGraph(page: Page) {
|
||||
export async function goToGraph(page: Page, closeDocPanel: boolean = true) {
|
||||
await page.goto('/')
|
||||
// Initial load through vite can take a while. Make sure that the first locator has enough time.
|
||||
await expect(page.locator('.GraphEditor')).toBeVisible({ timeout: 100000 })
|
||||
if (closeDocPanel) {
|
||||
await page.locator('.rightDock > .closeButton').click()
|
||||
}
|
||||
// Wait until nodes are loaded.
|
||||
await expect(locate.graphNode(page)).toExist()
|
||||
// Wait for position initialization
|
||||
|
@ -23,6 +23,16 @@ export class AbortScope {
|
||||
this.onAbort(disposable.dispose.bind(disposable))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new abort scope with lifetime limited by this scope. It can be aborted earlier on its
|
||||
* own, but it is guaranteed to be aborted whenever the parent scope aborts.
|
||||
*/
|
||||
child(): AbortScope {
|
||||
const child = new AbortScope()
|
||||
this.handleDispose(child)
|
||||
return child
|
||||
}
|
||||
|
||||
onAbort(listener: () => void) {
|
||||
if (this.signal.aborted) {
|
||||
setTimeout(listener, 0)
|
||||
|
@ -32,10 +32,10 @@ import {
|
||||
useEvent,
|
||||
useResizeObserver,
|
||||
} from '@/composables/events'
|
||||
import { useNavigatorStorage } from '@/composables/navigatorStorage'
|
||||
import { groupColorVar } from '@/composables/nodeColors'
|
||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||
import { useStackNavigator } from '@/composables/stackNavigator'
|
||||
import { useSyncLocalStorage } from '@/composables/syncLocalStorage'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideNodeColors } from '@/providers/graphNodeColors'
|
||||
import { provideNodeCreation } from '@/providers/graphNodeCreation'
|
||||
@ -56,9 +56,11 @@ import { every, filterDefined } from '@/util/data/iterable'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { unwrapOr } from '@/util/data/result'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { computedFallback } from '@/util/reactivity'
|
||||
import { until } from '@vueuse/core'
|
||||
import { encoding, set } from 'lib0'
|
||||
import { encodeMethodPointer } from 'shared/languageServerTypes'
|
||||
import { computed, onMounted, ref, shallowRef, toRef, watch, watchEffect } from 'vue'
|
||||
import { computed, onMounted, ref, shallowRef, toRef, watch } from 'vue'
|
||||
|
||||
const keyboard = provideKeyboard()
|
||||
const graphStore = useGraphStore()
|
||||
@ -72,51 +74,98 @@ const suggestionDb = useSuggestionDbStore()
|
||||
const viewportNode = ref<HTMLElement>()
|
||||
onMounted(() => viewportNode.value?.focus())
|
||||
const graphNavigator = provideGraphNavigator(viewportNode, keyboard)
|
||||
useNavigatorStorage(
|
||||
graphNavigator,
|
||||
(enc) => {
|
||||
// Navigator viewport needs to be stored separately for:
|
||||
|
||||
// === Client saved state ===
|
||||
|
||||
const storedShowDocumentationEditor = ref()
|
||||
const rightDockWidth = ref<number>()
|
||||
|
||||
/**
|
||||
* JSON serializable representation of graph state saved in localStorage. The names of fields here
|
||||
* are kept relatively short, because it will be common to store hundreds of them within one big
|
||||
* JSON object, and serialize it quite often whenever the state is modified. Shorter keys end up
|
||||
* costing less localStorage space and slightly reduce serialization overhead.
|
||||
*/
|
||||
interface GraphStoredState {
|
||||
/** Navigator position X */
|
||||
x: number
|
||||
/** Navigator position Y */
|
||||
y: number
|
||||
/** Navigator scale */
|
||||
s: number
|
||||
/** Whether or not the documentation panel is open. */
|
||||
doc: boolean
|
||||
/** Width of the right dock. */
|
||||
rwidth: number | null
|
||||
}
|
||||
|
||||
const visibleAreasReady = computed(() => {
|
||||
const nodesCount = graphStore.db.nodeIdToNode.size
|
||||
const visibleNodeAreas = graphStore.visibleNodeAreas
|
||||
return nodesCount > 0 && visibleNodeAreas.length == nodesCount
|
||||
})
|
||||
|
||||
useSyncLocalStorage<GraphStoredState>({
|
||||
storageKey: 'enso-graph-state',
|
||||
mapKeyEncoder: (enc) => {
|
||||
// Client graph state needs to be stored separately for:
|
||||
// - each project
|
||||
// - each function within the project
|
||||
encoding.writeVarString(enc, projectStore.name)
|
||||
const methodPtr = graphStore.currentMethodPointer()
|
||||
if (methodPtr != null) encodeMethodPointer(enc, methodPtr)
|
||||
},
|
||||
waitInitializationAndPanToAll,
|
||||
)
|
||||
|
||||
let stopInitialization: (() => void) | undefined
|
||||
function waitInitializationAndPanToAll() {
|
||||
stopInitialization?.()
|
||||
stopInitialization = watchEffect(() => {
|
||||
const nodesCount = graphStore.db.nodeIdToNode.size
|
||||
const visibleNodeAreas = graphStore.visibleNodeAreas
|
||||
if (nodesCount > 0 && visibleNodeAreas.length == nodesCount) {
|
||||
zoomToSelected(true)
|
||||
stopInitialization?.()
|
||||
stopInitialization = undefined
|
||||
debounce: 200,
|
||||
captureState() {
|
||||
return {
|
||||
x: graphNavigator.targetCenter.x,
|
||||
y: graphNavigator.targetCenter.y,
|
||||
s: graphNavigator.targetScale,
|
||||
doc: storedShowDocumentationEditor.value,
|
||||
rwidth: rightDockWidth.value ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
async restoreState(restored, abort) {
|
||||
if (restored) {
|
||||
const pos = new Vec2(restored.x ?? 0, restored.y ?? 0)
|
||||
const scale = restored.s ?? 1
|
||||
graphNavigator.setCenterAndScale(pos, scale)
|
||||
storedShowDocumentationEditor.value = restored.doc ?? undefined
|
||||
rightDockWidth.value = restored.rwidth ?? undefined
|
||||
} else {
|
||||
await until(visibleAreasReady).toBe(true)
|
||||
if (!abort.aborted) zoomToAll(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function selectionBounds() {
|
||||
if (!viewportNode.value) return
|
||||
const selected = nodeSelection.selected
|
||||
const nodesToCenter = selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : selected
|
||||
function nodesBounds(nodeIds: Iterable<NodeId>) {
|
||||
let bounds = Rect.Bounding()
|
||||
for (const id of nodesToCenter) {
|
||||
for (const id of nodeIds) {
|
||||
const rect = graphStore.visibleArea(id)
|
||||
if (rect) bounds = Rect.Bounding(bounds, rect)
|
||||
}
|
||||
if (bounds.isFinite()) return bounds
|
||||
}
|
||||
|
||||
function selectionBounds() {
|
||||
const selected = nodeSelection.selected
|
||||
const nodesToCenter = selected.size === 0 ? graphStore.db.nodeIdToNode.keys() : selected
|
||||
return nodesBounds(nodesToCenter)
|
||||
}
|
||||
|
||||
function zoomToSelected(skipAnimation: boolean = false) {
|
||||
const bounds = selectionBounds()
|
||||
if (bounds)
|
||||
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale), skipAnimation)
|
||||
}
|
||||
|
||||
function zoomToAll(skipAnimation: boolean = false) {
|
||||
const bounds = nodesBounds(graphStore.db.nodeIdToNode.keys())
|
||||
if (bounds)
|
||||
graphNavigator.panAndZoomTo(bounds, 0.1, Math.max(1, graphNavigator.targetScale), skipAnimation)
|
||||
}
|
||||
|
||||
function panToSelected() {
|
||||
const bounds = selectionBounds()
|
||||
if (bounds)
|
||||
@ -307,7 +356,11 @@ const codeEditorHandler = codeEditorBindings.handler({
|
||||
// === Documentation Editor ===
|
||||
|
||||
const documentationEditorArea = ref<HTMLElement>()
|
||||
const showDocumentationEditor = ref(false)
|
||||
const showDocumentationEditor = computedFallback(
|
||||
storedShowDocumentationEditor,
|
||||
// Show documenation editor when documentation exists on first graph visit.
|
||||
() => !!documentation.value,
|
||||
)
|
||||
|
||||
const documentationEditorHandler = documentationEditorBindings.handler({
|
||||
toggle() {
|
||||
@ -317,7 +370,6 @@ const documentationEditorHandler = documentationEditorBindings.handler({
|
||||
|
||||
const rightDockComputedSize = useResizeObserver(documentationEditorArea)
|
||||
const rightDockComputedBounds = computed(() => new Rect(Vec2.Zero, rightDockComputedSize.value))
|
||||
const rightDockWidth = ref<number>()
|
||||
const cssRightDockWidth = computed(() =>
|
||||
rightDockWidth.value != null ? `${rightDockWidth.value}px` : 'var(--right-dock-default-width)',
|
||||
)
|
||||
|
@ -1,75 +0,0 @@
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
|
||||
import { encoding } from 'lib0'
|
||||
import { xxHash128 } from 'shared/ast/ffi'
|
||||
import { computed, nextTick, watch } from 'vue'
|
||||
import type { NavigatorComposable } from './navigator'
|
||||
|
||||
/**
|
||||
* Synchronize given navigator's viewport pan and zoom with `localStorage`.
|
||||
*
|
||||
* @param navigator The navigator representing a viewport to synchronize.
|
||||
* @param reactiveStorageKeyEncoder A **reactive** encoder from which a storage key is derived. Data
|
||||
* that is encoded in this function dictates the effective identity of stored viewport. Whenever the
|
||||
* encoded data changes, the stored viewport value is restored to navigator.
|
||||
* @param initializeViewport A function that will be called when no stored viewport is found for the
|
||||
* current storage key.
|
||||
*/
|
||||
export function useNavigatorStorage(
|
||||
navigator: NavigatorComposable,
|
||||
reactiveStorageKeyEncoder: (enc: encoding.Encoder) => void,
|
||||
initializeViewport: () => void,
|
||||
) {
|
||||
const graphViewportStorageKey = computed(() =>
|
||||
xxHash128(encoding.encode(reactiveStorageKeyEncoder)),
|
||||
)
|
||||
|
||||
type ViewportStorage = Map<string, { x: number; y: number; s: number }>
|
||||
const storedViewport = useLocalStorage<ViewportStorage>('enso-viewport', new Map())
|
||||
/**
|
||||
* Maximum number of viewports stored in localStorage. When it is exceeded, least recently used
|
||||
* half of the stored data is removed.
|
||||
*/
|
||||
const MAX_STORED_VIEWPORTS = 256
|
||||
|
||||
// Save/Load viewports whenever entering a new graph context (i.e. the storage key has changed).
|
||||
watch(
|
||||
graphViewportStorageKey,
|
||||
(key, prevKey) => {
|
||||
if (key === prevKey) return
|
||||
if (prevKey != null) storeCurrentViewport(prevKey)
|
||||
restoreViewport(key)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Whenever the viewport was changed and stable for a while, save it in localstorage.
|
||||
debouncedWatch(
|
||||
() => [navigator.targetCenter, navigator.targetScale],
|
||||
() => storeCurrentViewport(graphViewportStorageKey.value),
|
||||
{ debounce: 200 },
|
||||
)
|
||||
|
||||
function storeCurrentViewport(storageKey: string) {
|
||||
const pos = navigator.targetCenter
|
||||
const scale = navigator.targetScale
|
||||
storedViewport.value.set(storageKey, { x: pos.x, y: pos.y, s: scale })
|
||||
// Ensure that the storage doesn't grow forever by periodically removing least recently
|
||||
// written half of entries when we reach a limit.
|
||||
if (storedViewport.value.size > MAX_STORED_VIEWPORTS) {
|
||||
let toRemove = storedViewport.value.size - MAX_STORED_VIEWPORTS / 2
|
||||
for (const key of storedViewport.value.keys()) {
|
||||
if (toRemove-- <= 0) break
|
||||
storedViewport.value.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function restoreViewport(storageKey: string) {
|
||||
const restored = storedViewport.value.get(storageKey)
|
||||
if (restored == null) nextTick(initializeViewport)
|
||||
const pos = restored ? Vec2.FromXY(restored).finiteOrZero() : Vec2.Zero
|
||||
const scale = restored?.s ?? 1
|
||||
navigator.setCenterAndScale(pos, scale)
|
||||
}
|
||||
}
|
142
app/gui2/src/composables/syncLocalStorage.ts
Normal file
142
app/gui2/src/composables/syncLocalStorage.ts
Normal file
@ -0,0 +1,142 @@
|
||||
import { useAbortScope } from '@/util/net'
|
||||
import { debouncedWatch, useLocalStorage } from '@vueuse/core'
|
||||
import { encoding } from 'lib0'
|
||||
import { xxHash128 } from 'shared/ast/ffi'
|
||||
import { assert } from 'shared/util/assert'
|
||||
import { AbortScope } from 'shared/util/net'
|
||||
import { computed, getCurrentInstance, ref, watch, withCtx } from 'vue'
|
||||
|
||||
export interface SyncLocalStorageOptions<StoredState> {
|
||||
/**
|
||||
* The main localStorage key under which a map of saved states will be stored.
|
||||
*/
|
||||
storageKey: string
|
||||
/**
|
||||
* The minimum amount of time a serialized state is stable before it is written to localStorage.
|
||||
*/
|
||||
debounce: number
|
||||
/**
|
||||
* A **reactive** key encoder used for distinguishing between separate stored map entries. Data
|
||||
* that is encoded in this function dictates the effective identity of stored state. Whenever the
|
||||
* encoded key changes, the current state is saved and state stored under new key is restored.
|
||||
*/
|
||||
mapKeyEncoder: (enc: encoding.Encoder) => void
|
||||
/**
|
||||
* **Reactive** current state serializer. Captures the environment data that will be stored in
|
||||
* localStorage. Returned object must be JSON-encodable. State will not be captured while async
|
||||
* restore is still in progress.
|
||||
*/
|
||||
captureState: () => StoredState
|
||||
/**
|
||||
* Stored state deserializer. Decodes previously stored state back to the environment. In case the
|
||||
* deserialization process is asynchronous, the `abort` signal must be respected - environment
|
||||
* should not be modified after abort has been signalled.
|
||||
*
|
||||
* The `state` is `undefined` when it hasn't been previously saved under current key.
|
||||
*/
|
||||
restoreState: (
|
||||
state: Partial<StoredState> | undefined,
|
||||
abort: AbortSignal,
|
||||
) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize local view state with `localStorage`. Supports saving and restoring multiple unique
|
||||
* states based on encoded identity key.
|
||||
*/
|
||||
export function useSyncLocalStorage<StoredState extends Object>(
|
||||
options: SyncLocalStorageOptions<StoredState>,
|
||||
) {
|
||||
const graphViewportStorageKey = computed(() => xxHash128(encoding.encode(options.mapKeyEncoder)))
|
||||
|
||||
// Ensure that restoreState function is run within component's context, allowing for temporary
|
||||
// watchers to be created for async/await purposes.
|
||||
const restoreStateInCtx = withCtx(
|
||||
options.restoreState,
|
||||
getCurrentInstance(),
|
||||
) as typeof options.restoreState
|
||||
|
||||
const storageMap = useLocalStorage<Map<string, StoredState>>(options.storageKey, new Map())
|
||||
|
||||
/**
|
||||
* Maximum number of graph states stored in localStorage. When it is exceeded, least recently used
|
||||
* half of the stored data is removed.
|
||||
*/
|
||||
const MAX_STORED_GRAPH_STATES = 256
|
||||
|
||||
const abortScope = useAbortScope()
|
||||
let restoreAbort: AbortScope | null
|
||||
function abortLastRestore() {
|
||||
if (restoreAbort) {
|
||||
restoreAbort.dispose('Restore aborted.')
|
||||
restoreAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
let nextRestoreId = 0
|
||||
const restoreIdInProgress = ref<number>()
|
||||
const serializedState = computed(() => options.captureState())
|
||||
|
||||
// Save/Load viewports whenever entering a new graph context (i.e. the storage key has changed).
|
||||
watch(
|
||||
graphViewportStorageKey,
|
||||
(key, prevKey) => {
|
||||
if (key === prevKey) return
|
||||
if (prevKey != null && restoreIdInProgress.value == null) {
|
||||
saveState(prevKey, serializedState.value)
|
||||
}
|
||||
restoreState(key)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Whenever the state was changed and stable for a while, save it in localStorage. Does not
|
||||
// perform saves when restore is still in progress.
|
||||
debouncedWatch(
|
||||
() => restoreIdInProgress.value == null && serializedState.value,
|
||||
() => {
|
||||
if (restoreIdInProgress.value == null) {
|
||||
saveState(graphViewportStorageKey.value, serializedState.value)
|
||||
}
|
||||
},
|
||||
{
|
||||
debounce: options.debounce,
|
||||
},
|
||||
)
|
||||
|
||||
function saveState(storageKey: string, state: StoredState) {
|
||||
storageMap.value.set(storageKey, state)
|
||||
// Ensure that the storage doesn't grow forever by periodically removing least recently
|
||||
// written half of entries when we reach a limit.
|
||||
if (storageMap.value.size > MAX_STORED_GRAPH_STATES) {
|
||||
let toRemove = storageMap.value.size - MAX_STORED_GRAPH_STATES / 2
|
||||
for (const key of storageMap.value.keys()) {
|
||||
if (toRemove-- <= 0) break
|
||||
storageMap.value.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreState(storageKey: string) {
|
||||
abortLastRestore()
|
||||
assert(restoreIdInProgress.value == null)
|
||||
|
||||
const thisRestoreId = nextRestoreId++
|
||||
restoreIdInProgress.value = thisRestoreId
|
||||
const restored = storageMap.value.get(storageKey)
|
||||
|
||||
restoreAbort = abortScope.child()
|
||||
restoreAbort.onAbort(() => {
|
||||
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await restoreStateInCtx(restored, restoreAbort.signal)
|
||||
} catch (e) {
|
||||
// Ignore promise rejections caused by aborted scope. Those are expected to happen.
|
||||
if (!restoreAbort.signal.aborted) throw e
|
||||
} finally {
|
||||
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
/** @file Functions for manipulating Vue reactive objects. */
|
||||
|
||||
import { defaultEquality } from '@/util/equals'
|
||||
import { debouncedWatch } from '@vueuse/core'
|
||||
import { nop } from 'lib0/function'
|
||||
import {
|
||||
callWithErrorHandling,
|
||||
@ -15,6 +16,7 @@ import {
|
||||
type MaybeRefOrGetter,
|
||||
type Ref,
|
||||
type WatchSource,
|
||||
type WritableComputedRef,
|
||||
} from 'vue'
|
||||
|
||||
/** Cast watch source to an observable ref. */
|
||||
@ -134,23 +136,23 @@ export function cachedGetter<T>(
|
||||
|
||||
/**
|
||||
* Same as `cachedGetter`, except that any changes will be not applied immediately, but only after
|
||||
* the timer set for `delayMs` milliseconds will expire. If any further update arrives in that
|
||||
* time, the timer is restarted
|
||||
* the timer set for `debounce` milliseconds will expire. If any further update arrives in that
|
||||
* time, the timer is restarted.
|
||||
*/
|
||||
export function debouncedGetter<T>(
|
||||
getter: () => T,
|
||||
delayMs: number,
|
||||
debounce: number,
|
||||
equalFn: (a: T, b: T) => boolean = defaultEquality,
|
||||
): Ref<T> {
|
||||
const valueRef = shallowRef<T>(getter())
|
||||
let currentTimer: ReturnType<typeof setTimeout> | undefined
|
||||
watch(getter, (newValue) => {
|
||||
clearTimeout(currentTimer)
|
||||
currentTimer = setTimeout(() => {
|
||||
debouncedWatch(
|
||||
getter,
|
||||
(newValue) => {
|
||||
const oldValue = valueRef.value
|
||||
if (!equalFn(oldValue, newValue)) valueRef.value = newValue
|
||||
}, delayMs)
|
||||
})
|
||||
},
|
||||
{ debounce },
|
||||
)
|
||||
return valueRef
|
||||
}
|
||||
|
||||
@ -162,3 +164,17 @@ export function syncSet<T>(target: Set<T>, newState: Set<T>) {
|
||||
|
||||
/** Type of the parameter of `toValue`. */
|
||||
export type ToValue<T> = MaybeRefOrGetter<T> | ComputedRef<T>
|
||||
|
||||
/**
|
||||
* A writable proxy computed value that reads a fallback value in case the base is `undefined`.
|
||||
* Useful for cases where we have a user-overridable behavior with a computed default.
|
||||
*/
|
||||
export function computedFallback<T>(
|
||||
base: Ref<T | undefined>,
|
||||
fallback: () => T,
|
||||
): WritableComputedRef<T> {
|
||||
return computed({
|
||||
get: () => base.value ?? fallback(),
|
||||
set: (val: T) => (base.value = val),
|
||||
})
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user