@typed/navigation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/DOM.d.ts +12 -0
- package/dist/DOM.d.ts.map +1 -0
- package/dist/DOM.js +89 -0
- package/dist/DOM.js.map +1 -0
- package/dist/Memory.d.ts +10 -0
- package/dist/Memory.d.ts.map +1 -0
- package/dist/Memory.js +55 -0
- package/dist/Memory.js.map +1 -0
- package/dist/Navigation.d.ts +117 -0
- package/dist/Navigation.d.ts.map +1 -0
- package/dist/Navigation.js +36 -0
- package/dist/Navigation.js.map +1 -0
- package/dist/_makeServerWindow.d.ts +16 -0
- package/dist/_makeServerWindow.d.ts.map +1 -0
- package/dist/_makeServerWindow.js +9 -0
- package/dist/_makeServerWindow.js.map +1 -0
- package/dist/cjs/DOM.d.ts +12 -0
- package/dist/cjs/DOM.d.ts.map +1 -0
- package/dist/cjs/DOM.js +118 -0
- package/dist/cjs/DOM.js.map +1 -0
- package/dist/cjs/Memory.d.ts +10 -0
- package/dist/cjs/Memory.d.ts.map +1 -0
- package/dist/cjs/Memory.js +82 -0
- package/dist/cjs/Memory.js.map +1 -0
- package/dist/cjs/Navigation.d.ts +117 -0
- package/dist/cjs/Navigation.d.ts.map +1 -0
- package/dist/cjs/Navigation.js +69 -0
- package/dist/cjs/Navigation.js.map +1 -0
- package/dist/cjs/_makeServerWindow.d.ts +16 -0
- package/dist/cjs/_makeServerWindow.d.ts.map +1 -0
- package/dist/cjs/_makeServerWindow.js +36 -0
- package/dist/cjs/_makeServerWindow.js.map +1 -0
- package/dist/cjs/constant.d.ts +2 -0
- package/dist/cjs/constant.d.ts.map +1 -0
- package/dist/cjs/constant.js +30 -0
- package/dist/cjs/constant.js.map +1 -0
- package/dist/cjs/dom-intent.d.ts +29 -0
- package/dist/cjs/dom-intent.d.ts.map +1 -0
- package/dist/cjs/dom-intent.js +173 -0
- package/dist/cjs/dom-intent.js.map +1 -0
- package/dist/cjs/history.d.ts +31 -0
- package/dist/cjs/history.d.ts.map +1 -0
- package/dist/cjs/history.js +115 -0
- package/dist/cjs/history.js.map +1 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +20 -0
- package/dist/cjs/index.js.map +1 -0
- package/dist/cjs/json.d.ts +13 -0
- package/dist/cjs/json.d.ts.map +1 -0
- package/dist/cjs/json.js +24 -0
- package/dist/cjs/json.js.map +1 -0
- package/dist/cjs/memory-intent.d.ts +28 -0
- package/dist/cjs/memory-intent.d.ts.map +1 -0
- package/dist/cjs/memory-intent.js +149 -0
- package/dist/cjs/memory-intent.js.map +1 -0
- package/dist/cjs/model.d.ts +22 -0
- package/dist/cjs/model.d.ts.map +1 -0
- package/dist/cjs/model.js +48 -0
- package/dist/cjs/model.js.map +1 -0
- package/dist/cjs/shared-intent.d.ts +15 -0
- package/dist/cjs/shared-intent.d.ts.map +1 -0
- package/dist/cjs/shared-intent.js +82 -0
- package/dist/cjs/shared-intent.js.map +1 -0
- package/dist/cjs/storage.d.ts +19 -0
- package/dist/cjs/storage.d.ts.map +1 -0
- package/dist/cjs/storage.js +101 -0
- package/dist/cjs/storage.js.map +1 -0
- package/dist/cjs/util.d.ts +5 -0
- package/dist/cjs/util.d.ts.map +1 -0
- package/dist/cjs/util.js +39 -0
- package/dist/cjs/util.js.map +1 -0
- package/dist/constant.d.ts +2 -0
- package/dist/constant.d.ts.map +1 -0
- package/dist/constant.js +4 -0
- package/dist/constant.js.map +1 -0
- package/dist/dom-intent.d.ts +29 -0
- package/dist/dom-intent.d.ts.map +1 -0
- package/dist/dom-intent.js +141 -0
- package/dist/dom-intent.js.map +1 -0
- package/dist/history.d.ts +31 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +88 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/intent.d.ts +31 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +157 -0
- package/dist/intent.js.map +1 -0
- package/dist/json.d.ts +13 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +17 -0
- package/dist/json.js.map +1 -0
- package/dist/memory-intent.d.ts +28 -0
- package/dist/memory-intent.d.ts.map +1 -0
- package/dist/memory-intent.js +117 -0
- package/dist/memory-intent.js.map +1 -0
- package/dist/model.d.ts +22 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +21 -0
- package/dist/model.js.map +1 -0
- package/dist/shared-intent.d.ts +15 -0
- package/dist/shared-intent.d.ts.map +1 -0
- package/dist/shared-intent.js +51 -0
- package/dist/shared-intent.js.map +1 -0
- package/dist/storage.d.ts +19 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +73 -0
- package/dist/storage.js.map +1 -0
- package/dist/tsconfig.cjs.build.tsbuildinfo +1 -0
- package/dist/util.d.ts +5 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +12 -0
- package/dist/util.js.map +1 -0
- package/eslintrc.json +3 -0
- package/package.json +38 -0
- package/project.json +43 -0
- package/src/DOM.test.ts +704 -0
- package/src/DOM.ts +165 -0
- package/src/Memory.test.ts +464 -0
- package/src/Memory.ts +102 -0
- package/src/Navigation.ts +192 -0
- package/src/_makeServerWindow.ts +28 -0
- package/src/constant.ts +5 -0
- package/src/dom-intent.ts +276 -0
- package/src/history.ts +141 -0
- package/src/index.ts +3 -0
- package/src/json.ts +31 -0
- package/src/memory-intent.ts +221 -0
- package/src/model.ts +54 -0
- package/src/shared-intent.ts +120 -0
- package/src/storage.ts +101 -0
- package/src/util.ts +20 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.cjs.build.json +22 -0
- package/tsconfig.json +27 -0
- package/vite.config.js +3 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as Cause from '@effect/io/Cause'
|
|
2
|
+
import * as Effect from '@effect/io/Effect'
|
|
3
|
+
import * as Fiber from '@effect/io/Fiber'
|
|
4
|
+
import * as Runtime from '@effect/io/Runtime'
|
|
5
|
+
import * as Scope from '@effect/io/Scope'
|
|
6
|
+
import { History } from '@typed/dom'
|
|
7
|
+
import * as Fx from '@typed/fx'
|
|
8
|
+
|
|
9
|
+
import { Destination, NavigationError } from './Navigation.js'
|
|
10
|
+
import { ServiceId } from './constant.js'
|
|
11
|
+
import { DomIntent } from './dom-intent.js'
|
|
12
|
+
|
|
13
|
+
export type HistoryEvent = PushStateEvent | ReplaceStateEvent | GoEvent | BackEvent | ForwardEvent
|
|
14
|
+
|
|
15
|
+
export interface PushStateEvent {
|
|
16
|
+
readonly _tag: 'PushState'
|
|
17
|
+
readonly state: unknown
|
|
18
|
+
readonly url: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReplaceStateEvent {
|
|
22
|
+
readonly _tag: 'ReplaceState'
|
|
23
|
+
readonly state: unknown
|
|
24
|
+
readonly url: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GoEvent {
|
|
28
|
+
readonly _tag: 'Go'
|
|
29
|
+
readonly delta: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BackEvent {
|
|
33
|
+
readonly _tag: 'Back'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ForwardEvent {
|
|
37
|
+
readonly _tag: 'Forward'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const patchHistory: Effect.Effect<
|
|
41
|
+
History | Scope.Scope,
|
|
42
|
+
never,
|
|
43
|
+
Fx.Subject<never, HistoryEvent>
|
|
44
|
+
> = Effect.gen(function* ($) {
|
|
45
|
+
const history = yield* $(History)
|
|
46
|
+
const scope = yield* $(Effect.scope)
|
|
47
|
+
const historyEvents = Fx.makeSubject<never, HistoryEvent>()
|
|
48
|
+
const runtime = yield* $(Effect.runtime<never>())
|
|
49
|
+
const runFork = Runtime.runFork(runtime)
|
|
50
|
+
const cleanup = patchHistory_(history, (event: HistoryEvent) =>
|
|
51
|
+
runFork(Effect.flatMap(Effect.forkIn(historyEvents.event(event), scope), Fiber.join)),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// unpatch history upon finalization
|
|
55
|
+
yield* $(Effect.addFinalizer(() => Effect.sync(cleanup)))
|
|
56
|
+
|
|
57
|
+
return historyEvents
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
function patchHistory_(history: History, sendEvent: (event: HistoryEvent) => void) {
|
|
61
|
+
const pushState = history.pushState.bind(history)
|
|
62
|
+
const replaceState = history.replaceState.bind(history)
|
|
63
|
+
const go = history.go.bind(history)
|
|
64
|
+
const back = history.back.bind(history)
|
|
65
|
+
const forward = history.forward.bind(history)
|
|
66
|
+
|
|
67
|
+
history.pushState = function (state, title, url) {
|
|
68
|
+
pushState(state, title, url)
|
|
69
|
+
|
|
70
|
+
if (url && this !== ServiceId) sendEvent({ _tag: 'PushState', state, url: url.toString() })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
history.replaceState = function (state, title, url) {
|
|
74
|
+
replaceState(state, title, url)
|
|
75
|
+
|
|
76
|
+
if (url && this !== ServiceId) sendEvent({ _tag: 'ReplaceState', state, url: url.toString() })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
history.go = function (delta) {
|
|
80
|
+
if (!delta) return
|
|
81
|
+
|
|
82
|
+
go(delta)
|
|
83
|
+
if (this !== ServiceId) sendEvent({ _tag: 'Go', delta })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
history.back = function () {
|
|
87
|
+
back()
|
|
88
|
+
if (this !== ServiceId) sendEvent({ _tag: 'Back' })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
history.forward = function () {
|
|
92
|
+
forward()
|
|
93
|
+
if (this !== ServiceId) sendEvent({ _tag: 'Forward' })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stateDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(history), 'state')
|
|
97
|
+
Object.defineProperty(history, 'state', {
|
|
98
|
+
...stateDescriptor,
|
|
99
|
+
get() {
|
|
100
|
+
return stateDescriptor?.get?.()?.state
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
Object.defineProperty(history, 'originalState', {
|
|
104
|
+
...stateDescriptor,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Reset history to original state
|
|
108
|
+
return () => {
|
|
109
|
+
history.pushState = pushState
|
|
110
|
+
history.replaceState = replaceState
|
|
111
|
+
history.go = go
|
|
112
|
+
history.back = back
|
|
113
|
+
history.forward = forward
|
|
114
|
+
|
|
115
|
+
if (stateDescriptor) Object.defineProperty(history, 'state', stateDescriptor)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function onHistoryEvent(
|
|
120
|
+
event: HistoryEvent,
|
|
121
|
+
intent: DomIntent,
|
|
122
|
+
): Effect.Effect<
|
|
123
|
+
History | Location | Storage,
|
|
124
|
+
Cause.NoSuchElementException | NavigationError,
|
|
125
|
+
Destination
|
|
126
|
+
> {
|
|
127
|
+
return Effect.gen(function* ($) {
|
|
128
|
+
switch (event._tag) {
|
|
129
|
+
case 'PushState':
|
|
130
|
+
return yield* $(intent.push(event.url, { state: event.state }, true))
|
|
131
|
+
case 'ReplaceState':
|
|
132
|
+
return yield* $(intent.replace(event.url, { state: event.state }, true))
|
|
133
|
+
case 'Back':
|
|
134
|
+
return yield* $(intent.back(true))
|
|
135
|
+
case 'Forward':
|
|
136
|
+
return yield* $(intent.forward(true))
|
|
137
|
+
case 'Go':
|
|
138
|
+
return yield* $(intent.go(event.delta, true))
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
package/src/index.ts
ADDED
package/src/json.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Destination, NavigationEvent } from './Navigation.js'
|
|
2
|
+
|
|
3
|
+
export type NavigationEventJson = {
|
|
4
|
+
readonly [K in keyof NavigationEvent]: NavigationEvent[K] extends Destination
|
|
5
|
+
? DestinationJson
|
|
6
|
+
: NavigationEvent[K]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type DestinationJson = {
|
|
10
|
+
readonly [K in keyof Destination]: Destination[K] extends URL ? string : Destination[K]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const decodeDestination = (d: DestinationJson): Destination => ({
|
|
14
|
+
...d,
|
|
15
|
+
url: new URL(d.url),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const decodeNavigationEvent = (event: NavigationEventJson): NavigationEvent => ({
|
|
19
|
+
...event,
|
|
20
|
+
destination: decodeDestination(event.destination),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const encodeEvent = (event: NavigationEvent): NavigationEventJson => ({
|
|
24
|
+
...event,
|
|
25
|
+
destination: encodeDestination(event.destination),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const encodeDestination = (destination: Destination): DestinationJson => ({
|
|
29
|
+
...destination,
|
|
30
|
+
url: destination.url.href,
|
|
31
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { Option } from '@effect/data/Option'
|
|
2
|
+
import * as Cause from '@effect/io/Cause'
|
|
3
|
+
import * as Effect from '@effect/io/Effect'
|
|
4
|
+
|
|
5
|
+
import type { MemoryNavigationOptions } from './Memory.js'
|
|
6
|
+
import {
|
|
7
|
+
Destination,
|
|
8
|
+
NavigateOptions,
|
|
9
|
+
NavigationError,
|
|
10
|
+
NavigationEvent,
|
|
11
|
+
NavigationType,
|
|
12
|
+
} from './Navigation.js'
|
|
13
|
+
import { Model } from './model.js'
|
|
14
|
+
import {
|
|
15
|
+
Notify,
|
|
16
|
+
Save,
|
|
17
|
+
makeGoTo,
|
|
18
|
+
makeNotify,
|
|
19
|
+
makeOnNavigation,
|
|
20
|
+
makeOnNavigationEnd,
|
|
21
|
+
} from './shared-intent.js'
|
|
22
|
+
import { createKey, getUrl } from './util.js'
|
|
23
|
+
|
|
24
|
+
// Roughly the number of History entries in a browser anyways
|
|
25
|
+
const DEFAULT_MAX_ENTRIES = 50
|
|
26
|
+
|
|
27
|
+
export type MemoryIntent = {
|
|
28
|
+
readonly back: ReturnType<ReturnType<typeof makeGo>>
|
|
29
|
+
|
|
30
|
+
readonly forward: ReturnType<ReturnType<typeof makeGo>>
|
|
31
|
+
|
|
32
|
+
readonly push: ReturnType<typeof makePush>
|
|
33
|
+
|
|
34
|
+
readonly replace: ReturnType<typeof makeReplace>
|
|
35
|
+
|
|
36
|
+
readonly navigate: (
|
|
37
|
+
url: string,
|
|
38
|
+
options?: NavigateOptions,
|
|
39
|
+
) => ReturnType<ReturnType<typeof makePush | typeof makeReplace>>
|
|
40
|
+
|
|
41
|
+
readonly notify: Notify
|
|
42
|
+
|
|
43
|
+
readonly go: ReturnType<typeof makeGo>
|
|
44
|
+
|
|
45
|
+
readonly goTo: (
|
|
46
|
+
key: string,
|
|
47
|
+
) => Effect.Effect<never, Cause.NoSuchElementException | NavigationError, Option<Destination>>
|
|
48
|
+
|
|
49
|
+
readonly reload: ReturnType<typeof makeReload>
|
|
50
|
+
|
|
51
|
+
readonly onNavigation: ReturnType<typeof makeOnNavigation>
|
|
52
|
+
|
|
53
|
+
readonly onNavigationEnd: ReturnType<typeof makeOnNavigationEnd>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function makeIntent(model: Model, options: MemoryNavigationOptions): MemoryIntent {
|
|
57
|
+
const { origin } = options.initialUrl
|
|
58
|
+
const base = options.base ?? '/'
|
|
59
|
+
const maxEntries = Math.abs(options.maxEntries ?? DEFAULT_MAX_ENTRIES)
|
|
60
|
+
const notify = makeNotify(model)
|
|
61
|
+
const save = makeSave(model)
|
|
62
|
+
const go = makeGo(model, notify, save)
|
|
63
|
+
const replace = makeReplace(model, notify, save, base, origin)
|
|
64
|
+
const push = makePush(model, notify, save, base, origin, maxEntries)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
back: go(-1),
|
|
68
|
+
forward: go(1),
|
|
69
|
+
push,
|
|
70
|
+
replace,
|
|
71
|
+
navigate: (url: string, options: NavigateOptions = {}) =>
|
|
72
|
+
options.history === 'replace' ? replace(url, options) : push(url, options),
|
|
73
|
+
go: go,
|
|
74
|
+
goTo: makeGoTo(model, go),
|
|
75
|
+
reload: makeReload(model, notify, save),
|
|
76
|
+
onNavigation: makeOnNavigation(model),
|
|
77
|
+
onNavigationEnd: makeOnNavigationEnd(model),
|
|
78
|
+
notify,
|
|
79
|
+
} as const
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type Intent = ReturnType<typeof makeIntent>
|
|
83
|
+
|
|
84
|
+
export const makeSave: (
|
|
85
|
+
model: Model,
|
|
86
|
+
) => (event: NavigationEvent) => Effect.Effect<never, Cause.NoSuchElementException, void> =
|
|
87
|
+
(model: Model) => (event: NavigationEvent) =>
|
|
88
|
+
Effect.gen(function* ($) {
|
|
89
|
+
const events = yield* $(model.events)
|
|
90
|
+
const index = yield* $(model.index)
|
|
91
|
+
|
|
92
|
+
// Update current entry
|
|
93
|
+
yield* $(model.currentEntry.set(event.destination))
|
|
94
|
+
|
|
95
|
+
// Update canGoBack
|
|
96
|
+
yield* $(model.canGoBack.set(index > 0))
|
|
97
|
+
|
|
98
|
+
// Update canGoForward
|
|
99
|
+
yield* $(model.canGoForward.set(index < events.length - 1))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export const makeReload = (model: Model, notify: Notify, save: Save<never>) =>
|
|
103
|
+
Effect.gen(function* ($) {
|
|
104
|
+
const i = yield* $(model.index.get)
|
|
105
|
+
const e = yield* $(model.events)
|
|
106
|
+
const event = e[i]
|
|
107
|
+
const reloadEvent = { ...event, navigationType: NavigationType.Reload }
|
|
108
|
+
|
|
109
|
+
yield* $(notify(reloadEvent))
|
|
110
|
+
yield* $(save(reloadEvent))
|
|
111
|
+
|
|
112
|
+
return event.destination
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export const makeReplace =
|
|
116
|
+
(model: Model, notify: Notify, save: Save<never>, base: string, origin: string) =>
|
|
117
|
+
(url: string, options: NavigateOptions = {}) =>
|
|
118
|
+
Effect.gen(function* ($) {
|
|
119
|
+
const entry = yield* $(model.currentEntry.get)
|
|
120
|
+
const destination: Destination = {
|
|
121
|
+
key: entry.key,
|
|
122
|
+
url: getUrl(url, base, origin),
|
|
123
|
+
state: options.state,
|
|
124
|
+
}
|
|
125
|
+
const event: NavigationEvent = {
|
|
126
|
+
destination,
|
|
127
|
+
hashChange: entry.url.hash !== destination.url.hash,
|
|
128
|
+
navigationType: NavigationType.Replace,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
yield* $(notify(event))
|
|
132
|
+
|
|
133
|
+
const currentIndex = yield* $(model.index)
|
|
134
|
+
|
|
135
|
+
yield* $(
|
|
136
|
+
model.events.update((entries) => {
|
|
137
|
+
const updated = entries.slice(0)
|
|
138
|
+
updated[currentIndex] = event
|
|
139
|
+
return updated
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
yield* $(save(event))
|
|
144
|
+
|
|
145
|
+
return destination
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
export const makePush =
|
|
149
|
+
(
|
|
150
|
+
model: Model,
|
|
151
|
+
notify: Notify,
|
|
152
|
+
save: Save<never>,
|
|
153
|
+
base: string,
|
|
154
|
+
origin: string,
|
|
155
|
+
maxEntries: number,
|
|
156
|
+
) =>
|
|
157
|
+
(url: string, options: NavigateOptions = {}) =>
|
|
158
|
+
Effect.gen(function* ($) {
|
|
159
|
+
const entry = yield* $(model.currentEntry.get)
|
|
160
|
+
const destination: Destination = {
|
|
161
|
+
key: yield* $(createKey),
|
|
162
|
+
url: getUrl(url, base, origin),
|
|
163
|
+
state: options.state,
|
|
164
|
+
}
|
|
165
|
+
const event: NavigationEvent = {
|
|
166
|
+
destination,
|
|
167
|
+
hashChange: entry.url.hash !== destination.url.hash,
|
|
168
|
+
navigationType: NavigationType.Push,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Notify event handlers
|
|
172
|
+
yield* $(notify(event))
|
|
173
|
+
|
|
174
|
+
const currentIndex = yield* $(model.index)
|
|
175
|
+
|
|
176
|
+
// Remove all entries after the current index
|
|
177
|
+
// and add the new destination to the end
|
|
178
|
+
yield* $(
|
|
179
|
+
model.events.update((entries) => {
|
|
180
|
+
const updated = entries.slice(0, currentIndex + 1)
|
|
181
|
+
updated.push(event)
|
|
182
|
+
return updated.slice(-maxEntries)
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Update the index to the new destination
|
|
187
|
+
yield* $(model.index.update((i) => i + 1))
|
|
188
|
+
|
|
189
|
+
yield* $(save(event))
|
|
190
|
+
|
|
191
|
+
return destination
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
export const makeGo = (model: Model, notify: Notify, save: Save<never>) => (delta: number) =>
|
|
195
|
+
Effect.gen(function* ($) {
|
|
196
|
+
const currentEntries = yield* $(model.events)
|
|
197
|
+
const totalEntries = currentEntries.length
|
|
198
|
+
const currentIndex = yield* $(model.index)
|
|
199
|
+
|
|
200
|
+
// Nothing to do here
|
|
201
|
+
if (delta === 0) return currentEntries[currentIndex].destination
|
|
202
|
+
|
|
203
|
+
const nextIndex =
|
|
204
|
+
delta > 0
|
|
205
|
+
? Math.min(currentIndex + delta, totalEntries - 1)
|
|
206
|
+
: Math.max(currentIndex + delta, 0)
|
|
207
|
+
const nextEntry = currentEntries[nextIndex]
|
|
208
|
+
|
|
209
|
+
yield* $(
|
|
210
|
+
notify({
|
|
211
|
+
...nextEntry,
|
|
212
|
+
navigationType: nextIndex > currentIndex ? NavigationType.Forward : NavigationType.Back,
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
yield* $(model.index.set(nextIndex))
|
|
217
|
+
|
|
218
|
+
yield* $(save(nextEntry))
|
|
219
|
+
|
|
220
|
+
return nextEntry.destination
|
|
221
|
+
})
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as Effect from '@effect/io/Effect'
|
|
2
|
+
import * as Scope from '@effect/io/Scope'
|
|
3
|
+
import * as Fx from '@typed/fx'
|
|
4
|
+
|
|
5
|
+
import { Destination, NavigationError, NavigationEvent, OnNavigationOptions } from './Navigation.js'
|
|
6
|
+
|
|
7
|
+
export interface Model {
|
|
8
|
+
readonly onNavigationHandlers: Set<
|
|
9
|
+
readonly [
|
|
10
|
+
(event: NavigationEvent) => Effect.Effect<never, NavigationError, unknown>,
|
|
11
|
+
OnNavigationOptions?,
|
|
12
|
+
]
|
|
13
|
+
>
|
|
14
|
+
readonly onNavigationEndHandlers: Set<
|
|
15
|
+
readonly [
|
|
16
|
+
(event: NavigationEvent) => Effect.Effect<never, never, unknown>,
|
|
17
|
+
OnNavigationOptions?,
|
|
18
|
+
]
|
|
19
|
+
>
|
|
20
|
+
readonly events: Fx.RefSubject<never, readonly NavigationEvent[]>
|
|
21
|
+
readonly index: Fx.RefSubject<never, number>
|
|
22
|
+
readonly entries: Fx.Computed<never, never, Destination[]>
|
|
23
|
+
readonly currentEntry: Fx.RefSubject<never, Destination>
|
|
24
|
+
readonly canGoBack: Fx.RefSubject<never, boolean>
|
|
25
|
+
readonly canGoForward: Fx.RefSubject<never, boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const makeModel = (
|
|
29
|
+
initialEntries: readonly NavigationEvent[],
|
|
30
|
+
initialIndex: number,
|
|
31
|
+
): Effect.Effect<Scope.Scope, never, Model> =>
|
|
32
|
+
Effect.gen(function* ($) {
|
|
33
|
+
const events = yield* $(Fx.makeRef(Effect.succeed(initialEntries)))
|
|
34
|
+
const index = yield* $(Fx.makeRef(Effect.succeed(initialIndex)))
|
|
35
|
+
const entries = events.map((es) => es.map((e) => e.destination))
|
|
36
|
+
const currentEntry = yield* $(
|
|
37
|
+
Fx.makeRef(Effect.succeed(initialEntries[initialIndex].destination)),
|
|
38
|
+
)
|
|
39
|
+
const canGoBack = yield* $(Fx.makeRef(Effect.succeed(initialIndex > 0)))
|
|
40
|
+
const canGoForward = yield* $(
|
|
41
|
+
Fx.makeRef(Effect.succeed(initialIndex < initialEntries.length - 1)),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
onNavigationHandlers: new Set(),
|
|
46
|
+
onNavigationEndHandlers: new Set(),
|
|
47
|
+
events,
|
|
48
|
+
index,
|
|
49
|
+
entries,
|
|
50
|
+
currentEntry,
|
|
51
|
+
canGoBack,
|
|
52
|
+
canGoForward,
|
|
53
|
+
}
|
|
54
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import * as Option from '@effect/data/Option'
|
|
2
|
+
import * as Cause from '@effect/io/Cause'
|
|
3
|
+
import * as Effect from '@effect/io/Effect'
|
|
4
|
+
import * as Scope from '@effect/io/Scope'
|
|
5
|
+
|
|
6
|
+
import { Destination, NavigationError, NavigationEvent, OnNavigationOptions } from './Navigation.js'
|
|
7
|
+
import { Model } from './model.js'
|
|
8
|
+
|
|
9
|
+
export type Notify = (event: NavigationEvent) => Effect.Effect<never, NavigationError, void>
|
|
10
|
+
export type NotifyEnd = (event: NavigationEvent) => Effect.Effect<never, never, void>
|
|
11
|
+
|
|
12
|
+
export type Save<R> = (
|
|
13
|
+
event: NavigationEvent,
|
|
14
|
+
) => Effect.Effect<R, Cause.NoSuchElementException, void>
|
|
15
|
+
|
|
16
|
+
// Anytime there are changes to the model, we need to notify all event handlers
|
|
17
|
+
export const makeNotify = (model: Model) => (event: NavigationEvent) =>
|
|
18
|
+
Effect.gen(function* ($) {
|
|
19
|
+
// Notify event handlers
|
|
20
|
+
if (model.onNavigationHandlers.size > 0)
|
|
21
|
+
yield* $(
|
|
22
|
+
Effect.forEach(
|
|
23
|
+
model.onNavigationHandlers,
|
|
24
|
+
([handler, options]) => (options?.passive ? Effect.fork(handler(event)) : handler(event)),
|
|
25
|
+
{ discard: true },
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const makeOnNavigation =
|
|
31
|
+
(model: Model) =>
|
|
32
|
+
<R>(
|
|
33
|
+
handler: (event: NavigationEvent) => Effect.Effect<R, NavigationError, unknown>,
|
|
34
|
+
options?: OnNavigationOptions,
|
|
35
|
+
): Effect.Effect<R | Scope.Scope, NavigationError, void> =>
|
|
36
|
+
Effect.uninterruptibleMask((restore) =>
|
|
37
|
+
Effect.gen(function* ($) {
|
|
38
|
+
const context = yield* $(Effect.context<R>())
|
|
39
|
+
const handler_ = (event: NavigationEvent) =>
|
|
40
|
+
restore(Effect.provideContext(handler(event), context))
|
|
41
|
+
const entry = [handler_, options] as const
|
|
42
|
+
|
|
43
|
+
model.onNavigationHandlers.add(entry)
|
|
44
|
+
|
|
45
|
+
yield* $(
|
|
46
|
+
Effect.addFinalizer(() => Effect.sync(() => model.onNavigationHandlers.delete(entry))),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
// Send the latest navigation event on subscription
|
|
50
|
+
const events = yield* $(model.events)
|
|
51
|
+
const index = yield* $(model.index)
|
|
52
|
+
const event = events[index]
|
|
53
|
+
|
|
54
|
+
yield* $(restore(handler(event)))
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// Anytime there are changes to the model, we need to notify all event handlers
|
|
59
|
+
export const makeNotifyEnd = (model: Model) => (event: NavigationEvent) =>
|
|
60
|
+
Effect.gen(function* ($) {
|
|
61
|
+
// Notify event handlers
|
|
62
|
+
if (model.onNavigationEndHandlers.size > 0)
|
|
63
|
+
yield* $(
|
|
64
|
+
Effect.forEach(
|
|
65
|
+
model.onNavigationEndHandlers,
|
|
66
|
+
([handler, options]) => (options?.passive ? Effect.fork(handler(event)) : handler(event)),
|
|
67
|
+
{ discard: true },
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const makeOnNavigationEnd =
|
|
73
|
+
(model: Model) =>
|
|
74
|
+
<R>(
|
|
75
|
+
handler: (event: NavigationEvent) => Effect.Effect<R, never, unknown>,
|
|
76
|
+
options?: OnNavigationOptions,
|
|
77
|
+
): Effect.Effect<R | Scope.Scope, never, void> =>
|
|
78
|
+
Effect.uninterruptibleMask((restore) =>
|
|
79
|
+
Effect.gen(function* ($) {
|
|
80
|
+
const context = yield* $(Effect.context<R>())
|
|
81
|
+
const handler_ = (event: NavigationEvent) =>
|
|
82
|
+
restore(Effect.provideContext(handler(event), context))
|
|
83
|
+
const entry = [handler_, options] as const
|
|
84
|
+
|
|
85
|
+
model.onNavigationEndHandlers.add(entry)
|
|
86
|
+
|
|
87
|
+
yield* $(
|
|
88
|
+
Effect.addFinalizer(() => Effect.sync(() => model.onNavigationEndHandlers.delete(entry))),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// Send the latest navigation event on subscription
|
|
92
|
+
const events = yield* $(model.events)
|
|
93
|
+
const index = yield* $(model.index)
|
|
94
|
+
const event = events[index]
|
|
95
|
+
|
|
96
|
+
yield* $(restore(handler(event)))
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
export const makeGoTo =
|
|
101
|
+
<R, E>(
|
|
102
|
+
model: Model,
|
|
103
|
+
go: (delta: number, skipHistory?: boolean) => Effect.Effect<R, E, Destination>,
|
|
104
|
+
) =>
|
|
105
|
+
(key: string): Effect.Effect<R, Cause.NoSuchElementException | E, Option.Option<Destination>> =>
|
|
106
|
+
Effect.gen(function* ($) {
|
|
107
|
+
const entries = yield* $(model.entries)
|
|
108
|
+
const currentIndex = yield* $(model.index)
|
|
109
|
+
const nextIndex = entries.findIndex((destination) => destination.key === key)
|
|
110
|
+
|
|
111
|
+
if (nextIndex === -1) return Option.none()
|
|
112
|
+
|
|
113
|
+
const delta = nextIndex - currentIndex
|
|
114
|
+
|
|
115
|
+
if (delta !== 0) {
|
|
116
|
+
return Option.some(yield* $(go(delta)))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return Option.some(entries[nextIndex])
|
|
120
|
+
})
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as Option from '@effect/data/Option'
|
|
2
|
+
import * as Effect from '@effect/io/Effect'
|
|
3
|
+
import { History, Location, getItem, setItem } from '@typed/dom'
|
|
4
|
+
|
|
5
|
+
import type { DomNavigationOptions } from './DOM.js'
|
|
6
|
+
import { NavigationEvent, Destination, NavigationType } from './Navigation.js'
|
|
7
|
+
import { NavigationEventJson, decodeNavigationEvent } from './json.js'
|
|
8
|
+
import { createKey, getUrl } from './util.js'
|
|
9
|
+
|
|
10
|
+
const TYPED_NAVIGATION_ENTRIES_KEY = '@typed/navigation/entries'
|
|
11
|
+
const TYPED_NAVIGATION_INDEX_KEY = '@typed/navigation/index'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export const getStoredEvents: Effect.Effect<Storage, never, readonly NavigationEvent[]> =
|
|
17
|
+
Effect.gen(function* ($) {
|
|
18
|
+
const option = yield* $(getItem(TYPED_NAVIGATION_ENTRIES_KEY))
|
|
19
|
+
|
|
20
|
+
if (Option.isNone(option)) {
|
|
21
|
+
return []
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (JSON.parse(option.value) as readonly NavigationEventJson[]).map(decodeNavigationEvent)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @internal
|
|
29
|
+
*/
|
|
30
|
+
export const getStoredIndex = Effect.gen(function* ($) {
|
|
31
|
+
const option = yield* $(getItem(TYPED_NAVIGATION_INDEX_KEY))
|
|
32
|
+
|
|
33
|
+
if (Option.isNone(option)) {
|
|
34
|
+
return Option.none()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const n = JSON.parse(option.value) as number
|
|
38
|
+
|
|
39
|
+
if (Number.isNaN(n)) {
|
|
40
|
+
return Option.none()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Option.some(n)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export const getInitialValues = (
|
|
50
|
+
base: string,
|
|
51
|
+
options: DomNavigationOptions,
|
|
52
|
+
): Effect.Effect<
|
|
53
|
+
Storage | History | Location,
|
|
54
|
+
never,
|
|
55
|
+
readonly [readonly NavigationEvent[], number]
|
|
56
|
+
> =>
|
|
57
|
+
Effect.gen(function* ($) {
|
|
58
|
+
// Get Resources
|
|
59
|
+
const history = yield* $(History)
|
|
60
|
+
const location = yield* $(Location)
|
|
61
|
+
|
|
62
|
+
// Read the stored entries and index
|
|
63
|
+
const storedEntries = yield* $(getStoredEvents)
|
|
64
|
+
const storedIndex = Option.getOrElse(yield* $(getStoredIndex), () => storedEntries.length - 1)
|
|
65
|
+
const storedEntry = storedEntries[storedIndex]
|
|
66
|
+
|
|
67
|
+
// Read the initial url from the location
|
|
68
|
+
const initialUrl = getUrl(location.href, base, location.origin)
|
|
69
|
+
const initial: Destination = {
|
|
70
|
+
key: options.initialKey ?? (yield* $(createKey)),
|
|
71
|
+
url: initialUrl,
|
|
72
|
+
state: history.state?.state,
|
|
73
|
+
}
|
|
74
|
+
const initialEvent: NavigationEvent = {
|
|
75
|
+
destination: initial,
|
|
76
|
+
hashChange: false,
|
|
77
|
+
navigationType: NavigationType.Push,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If there are no stored entries then we can just use the initial entry
|
|
81
|
+
if (!storedEntry) {
|
|
82
|
+
return [[initialEvent], 0] as const
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// If we're starting on the same page as the initial entry
|
|
86
|
+
// then we can just use the initial entries
|
|
87
|
+
if (storedEntry.destination.url.href === initialUrl.href) {
|
|
88
|
+
return [storedEntries, storedIndex] as const
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Otherwise, we need to push the initial entry with the current page
|
|
92
|
+
const entries = [...storedEntries.slice(0, storedIndex + 1), initialEvent]
|
|
93
|
+
|
|
94
|
+
return [entries, entries.length - 1] as const
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export const saveToStorage = (entries: readonly NavigationEvent[], index: number) =>
|
|
98
|
+
Effect.gen(function* ($) {
|
|
99
|
+
yield* $(setItem(TYPED_NAVIGATION_ENTRIES_KEY, JSON.stringify(entries)))
|
|
100
|
+
yield* $(setItem(TYPED_NAVIGATION_INDEX_KEY, JSON.stringify(index)))
|
|
101
|
+
})
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as Effect from '@effect/io/Effect'
|
|
2
|
+
import { pathJoin } from '@typed/path'
|
|
3
|
+
|
|
4
|
+
import { DestinationKey } from './Navigation.js'
|
|
5
|
+
|
|
6
|
+
export function getUrl(href: string, base: string, origin: string) {
|
|
7
|
+
const url = new URL(href, origin)
|
|
8
|
+
|
|
9
|
+
if (!url.pathname.startsWith(base)) {
|
|
10
|
+
url.pathname = pathJoin(base, url.pathname)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return url
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createKey = Effect.randomWith((random) =>
|
|
17
|
+
Effect.map(random.nextIntBetween(0, Number.MAX_SAFE_INTEGER), (n) =>
|
|
18
|
+
DestinationKey(n.toString(36).slice(2, 10)),
|
|
19
|
+
),
|
|
20
|
+
)
|