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:
Paweł Grabarz 2024-05-27 14:53:16 +02:00 committed by GitHub
parent f33aead7ef
commit ca916b823e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 261 additions and 113 deletions

View File

@ -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

View File

@ -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)

View File

@ -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)',
)

View File

@ -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)
}
}

View 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
}
}
}

View File

@ -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),
})
}