@weft-js/multiplayer 0.1.1 → 0.1.4
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/README.md +435 -3
- package/package.json +25 -18
- package/src/Cursors.tsx +15 -4
- package/src/conformance.ts +50 -14
- package/src/index/cursors.ts +196 -0
- package/src/index/peer.ts +26 -0
- package/src/index/presence.ts +102 -0
- package/src/index/shared.ts +13 -0
- package/src/index.ts +109 -234
- package/src/server.ts +186 -59
package/src/index.ts
CHANGED
|
@@ -1,29 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import type
|
|
1
|
+
import {
|
|
2
|
+
derived,
|
|
3
|
+
effect,
|
|
4
|
+
onCleanup,
|
|
5
|
+
onMount,
|
|
6
|
+
state,
|
|
7
|
+
untrack,
|
|
8
|
+
type Derived,
|
|
9
|
+
type State,
|
|
10
|
+
} from '@weft-js/core'
|
|
11
|
+
import { hear, isServer, silent, type Conn } from './index/shared'
|
|
12
|
+
import { listenPresence, openTransport } from './index/presence'
|
|
12
13
|
|
|
13
14
|
/** Who a peer is, from `user` on the server: never from the client. */
|
|
14
|
-
export interface PeerUser {
|
|
15
|
+
export interface PeerUser {
|
|
16
|
+
id: string
|
|
17
|
+
name?: string
|
|
18
|
+
[key: string]: unknown
|
|
19
|
+
}
|
|
15
20
|
/** Someone else in the room: their connection's id, who they are, the state they share and when they were last heard. */
|
|
16
|
-
export interface Peer<S = unknown> {
|
|
21
|
+
export interface Peer<S = unknown> {
|
|
22
|
+
id: string
|
|
23
|
+
user: PeerUser
|
|
24
|
+
state: S
|
|
25
|
+
at: number
|
|
26
|
+
}
|
|
17
27
|
/** A pointer as fractions of `a`, an anchor's id, or of the viewport without one. */
|
|
18
|
-
export interface CursorAt {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
send(name: string, message: unknown): Promise<void>
|
|
28
|
+
export interface CursorAt {
|
|
29
|
+
a?: string
|
|
30
|
+
x: number
|
|
31
|
+
y: number
|
|
32
|
+
style?: string
|
|
24
33
|
}
|
|
25
|
-
/** One of the fragment's messages, whose shape the server's half decides. */
|
|
26
|
-
const hear = <M,>(conn: Pick<Conn, 'on'>, name: string, fn: (message: M) => void) => conn.on(name, fn as (message: unknown) => void)
|
|
27
34
|
|
|
28
35
|
/** What a transport pack is handed to carry cursors around the server (`@weft-js/realtime-webrtc`, `-livekit`, `-socketio`). */
|
|
29
36
|
export interface PeerRoom {
|
|
@@ -79,12 +86,12 @@ export interface Presence<S> {
|
|
|
79
86
|
|
|
80
87
|
const STATE_GAP = 50
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
export const applyPeer = <S,>(list: Peer<S>[], p: Peer<S>): Peer<S>[] => (list.some(x => x.id === p.id) ? list.map(x => (x.id === p.id ? p : x)) : [...list, p])
|
|
89
|
+
export { applyPeer, readCursor } from './index/peer'
|
|
84
90
|
/** The list without peers unheard for `ttl`, or the same list when nobody expired. */
|
|
85
|
-
export const expire = <S
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
export const expire = <S>(list: Peer<S>[], now: number, ttl: number): Peer<S>[] => {
|
|
92
|
+
const kept = list.filter((p) => now - p.at <= ttl)
|
|
93
|
+
return kept.length === list.length ? list : kept
|
|
94
|
+
}
|
|
88
95
|
|
|
89
96
|
/** Who else is on this connection. Announces this one, follows `state`, and expires peers that stop beating. */
|
|
90
97
|
export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S> = {}): Presence<S> {
|
|
@@ -93,48 +100,35 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
|
|
|
93
100
|
const list = state<Peer<S>[]>([])
|
|
94
101
|
const self = state<string | undefined>(undefined)
|
|
95
102
|
const ticket = state<unknown>(undefined)
|
|
96
|
-
const own = () =>
|
|
103
|
+
const own = () =>
|
|
104
|
+
(typeof options.state === 'function' ? (options.state as () => S)() : options.state) ?? null
|
|
97
105
|
const hello = () => silent(conn.send('mp.hello', { state: untrack(own) }))
|
|
98
|
-
const ids = derived(() => list.value.map(p => p.id))
|
|
106
|
+
const ids = derived(() => list.value.map((p) => p.id))
|
|
99
107
|
const heard = new Set<(id: string, at: CursorAt | null) => void>()
|
|
100
108
|
const signals = new Set<(from: string, data: unknown) => void>()
|
|
101
|
-
let link: PeerLink | undefined
|
|
102
109
|
const linked = state<PeerLink | undefined>(undefined)
|
|
103
110
|
|
|
104
|
-
|
|
105
|
-
self.value = id
|
|
106
|
-
ticket.value = t
|
|
107
|
-
})
|
|
108
|
-
hear(conn, 'mp.peer', (p: Peer<S>) => {
|
|
109
|
-
if (p.id === untrack(() => self.value)) return
|
|
110
|
-
// Stamped by this clock: another machine's would expire it early or never.
|
|
111
|
-
list.value = applyPeer(untrack(() => list.value), { ...p, at: Date.now() })
|
|
112
|
-
})
|
|
113
|
-
hear(conn, 'mp.gone', ({ id }: { id: string }) => {
|
|
114
|
-
list.value = untrack(() => list.value).filter(p => p.id !== id)
|
|
115
|
-
for (const f of heard) f(id, null)
|
|
116
|
-
})
|
|
117
|
-
hear(conn, 'mp.ask', ({ id }: { id: string }) => silent(conn.send('mp.answer', { to: id, state: untrack(own) })))
|
|
118
|
-
hear(conn, 'mp.cursor', ({ id, at }: { id: string; at: CursorAt | null }) => { for (const f of heard) f(id, at) })
|
|
119
|
-
hear(conn, 'mp.signal', ({ from, data }: { from: string; data: unknown }) => { for (const f of signals) f(from, data) })
|
|
120
|
-
conn.onReset(() => {
|
|
121
|
-
list.value = []
|
|
122
|
-
hello()
|
|
123
|
-
})
|
|
111
|
+
listenPresence({ conn, self, ticket, list, heard, signals, own, hello })
|
|
124
112
|
|
|
125
113
|
// Open, or moved to another room: everything held is about the last one.
|
|
126
|
-
effect(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
114
|
+
effect(
|
|
115
|
+
() => (conn.status.value === 'open' ? conn.room.value : undefined),
|
|
116
|
+
(room, was) => {
|
|
117
|
+
if (room === was) return
|
|
118
|
+
list.value = []
|
|
119
|
+
self.value = undefined
|
|
120
|
+
if (room) hello()
|
|
121
|
+
},
|
|
122
|
+
)
|
|
132
123
|
|
|
133
124
|
let first = true
|
|
134
|
-
effect(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
125
|
+
effect(
|
|
126
|
+
() => own(),
|
|
127
|
+
() => {
|
|
128
|
+
if (first) return void (first = false)
|
|
129
|
+
if (untrack(() => conn.status.value) === 'open') schedule()
|
|
130
|
+
},
|
|
131
|
+
)
|
|
138
132
|
let queued: ReturnType<typeof setTimeout> | undefined
|
|
139
133
|
const schedule = () => {
|
|
140
134
|
queued ??= setTimeout(() => {
|
|
@@ -145,8 +139,13 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
|
|
|
145
139
|
|
|
146
140
|
if (typeof setInterval !== 'undefined' && !isServer()) {
|
|
147
141
|
const timer = setInterval(() => {
|
|
148
|
-
list.value = expire(
|
|
149
|
-
|
|
142
|
+
list.value = expire(
|
|
143
|
+
untrack(() => list.value),
|
|
144
|
+
Date.now(),
|
|
145
|
+
ttl,
|
|
146
|
+
)
|
|
147
|
+
if (untrack(() => conn.status.value) === 'open')
|
|
148
|
+
silent(conn.send('mp.state', { state: untrack(own) }))
|
|
150
149
|
}, beat)
|
|
151
150
|
onCleanup(() => {
|
|
152
151
|
clearInterval(timer)
|
|
@@ -154,182 +153,43 @@ export function presence<S = undefined>(conn: Conn, options: PresenceOptions<S>
|
|
|
154
153
|
})
|
|
155
154
|
}
|
|
156
155
|
|
|
157
|
-
if (transport) {
|
|
158
|
-
effect(() => [self.value, ticket.value] as const, ([me, t]) => {
|
|
159
|
-
link?.close()
|
|
160
|
-
link = undefined
|
|
161
|
-
linked.value = undefined
|
|
162
|
-
if (!me) return
|
|
163
|
-
link = transport.open({
|
|
164
|
-
self: me,
|
|
165
|
-
ticket: t,
|
|
166
|
-
peers: ids,
|
|
167
|
-
signal: (to, data) => silent(conn.send('mp.signal', { to, data })),
|
|
168
|
-
onSignal: fn => { signals.add(fn); return () => void signals.delete(fn) },
|
|
169
|
-
receive(from, text) {
|
|
170
|
-
const at = readCursor(text)
|
|
171
|
-
if (at !== undefined && untrack(() => ids.value).includes(from)) for (const f of heard) f(from, at)
|
|
172
|
-
},
|
|
173
|
-
})
|
|
174
|
-
linked.value = link
|
|
175
|
-
})
|
|
176
|
-
onCleanup(() => link?.close())
|
|
177
|
-
}
|
|
156
|
+
if (transport) openTransport({ transport, self, ticket, ids, linked, conn, signals, heard })
|
|
178
157
|
|
|
179
158
|
return {
|
|
180
159
|
peers: list,
|
|
181
160
|
self,
|
|
182
161
|
via: derived(() => (linked.value?.up.value ? transport!.name : 'socket')),
|
|
183
162
|
connection: conn,
|
|
184
|
-
link: () => {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
export function readCursor(text: string): CursorAt | null | undefined {
|
|
193
|
-
let parsed: unknown
|
|
194
|
-
try { parsed = JSON.parse(text) } catch { return undefined }
|
|
195
|
-
if (parsed === null) return null
|
|
196
|
-
if (typeof parsed !== 'object') return undefined
|
|
197
|
-
const v = parsed as Record<string, unknown>
|
|
198
|
-
const unit = (n: unknown): n is number => typeof n === 'number' && n >= -0.5 && n <= 1.5
|
|
199
|
-
if (!unit(v.x) || !unit(v.y)) return undefined
|
|
200
|
-
if (v.a !== undefined && (typeof v.a !== 'string' || v.a.length > 128)) return undefined
|
|
201
|
-
return { x: v.x, y: v.y, ...(typeof v.a === 'string' ? { a: v.a } : {}), ...(typeof v.style === 'string' && v.style.length <= 16 ? { style: v.style } : {}) }
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ---- cursors --------------------------------------------------------------------------------------------------------
|
|
205
|
-
|
|
206
|
-
/** Options of `cursors()`. */
|
|
207
|
-
export interface CursorOptions {
|
|
208
|
-
/** What a position is measured from. An element's id, in this attribute, on both sides. Default `data-anchor`. */
|
|
209
|
-
anchor?: string
|
|
210
|
-
/** Sends a second. Default 12, which keeps a socket under its default 20 a second with room for everything else. */
|
|
211
|
-
rate?: number
|
|
212
|
-
/** Ms before a still cursor fades. Default 5000. */
|
|
213
|
-
idle?: number
|
|
214
|
-
/** Where the pointer is followed. Default the document. */
|
|
215
|
-
root?: () => HTMLElement | Document
|
|
216
|
-
style?: string
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** One other cursor, placed on this screen. `peer` is missing until their presence arrives. */
|
|
220
|
-
export interface LiveCursor<S = unknown> { id: string; peer?: Peer<S> | undefined; at: CursorAt; left: number; top: number; moved: number; idle: boolean }
|
|
221
|
-
/** What `cursors()` returns, for `<Cursors of={…} />`. */
|
|
222
|
-
export interface Cursors<S = unknown> { readonly list: Derived<LiveCursor<S>[]>; readonly anchor: string }
|
|
223
|
-
|
|
224
|
-
/** Where a local pointer is, relative to the nearest anchor, or the viewport without one. Pure but for reading layout. */
|
|
225
|
-
export function locate(target: Element | null, x: number, y: number, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): CursorAt {
|
|
226
|
-
const el = target?.closest?.(`[${attr}]`) as HTMLElement | null | undefined
|
|
227
|
-
if (el) {
|
|
228
|
-
const r = el.getBoundingClientRect()
|
|
229
|
-
const w = Math.max(el.scrollWidth, r.width) || 1, h = Math.max(el.scrollHeight, r.height) || 1
|
|
230
|
-
return { a: el.getAttribute(attr)!, x: (x - r.left + el.scrollLeft) / w, y: (y - r.top + el.scrollTop) / h }
|
|
163
|
+
link: () => {
|
|
164
|
+
const l = untrack(() => linked.value)
|
|
165
|
+
return l && untrack(() => l.up.value) ? l : undefined
|
|
166
|
+
},
|
|
167
|
+
seen: (fn) => {
|
|
168
|
+
heard.add(fn)
|
|
169
|
+
onCleanup(() => void heard.delete(fn))
|
|
170
|
+
},
|
|
231
171
|
}
|
|
232
|
-
return { x: x / view.width, y: y / view.height }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** The same spot on this screen. Null when the anchor isn't here: a cursor at (0,0) would be a lie. */
|
|
236
|
-
export function resolve(at: CursorAt, attr = 'data-anchor', view = { width: innerWidth, height: innerHeight }): { left: number; top: number } | null {
|
|
237
|
-
if (!at.a) return { left: at.x * view.width, top: at.y * view.height }
|
|
238
|
-
const el = document.querySelector<HTMLElement>(`[${attr}="${CSS.escape(at.a)}"]`)
|
|
239
|
-
if (!el) return null
|
|
240
|
-
const r = el.getBoundingClientRect()
|
|
241
|
-
return { left: r.left + at.x * Math.max(el.scrollWidth, r.width) - el.scrollLeft, top: r.top + at.y * Math.max(el.scrollHeight, r.height) - el.scrollTop }
|
|
242
172
|
}
|
|
243
173
|
|
|
244
|
-
|
|
245
|
-
export
|
|
246
|
-
const { anchor = 'data-anchor', rate = 12, idle = 5000, root, style } = options
|
|
247
|
-
const gap = 1000 / rate
|
|
248
|
-
const raw = state<Record<string, { at: CursorAt; moved: number }>>({})
|
|
249
|
-
const layout = state(0)
|
|
250
|
-
const clock = state(0)
|
|
251
|
-
const send = (at: CursorAt | null) => {
|
|
252
|
-
const link = room.link()
|
|
253
|
-
if (link) link.send(JSON.stringify(at))
|
|
254
|
-
else silent(room.connection.send('mp.cursor', at))
|
|
255
|
-
}
|
|
256
|
-
room.seen((id, at) => {
|
|
257
|
-
const { [id]: _, ...rest } = untrack(() => raw.value)
|
|
258
|
-
raw.value = at ? { ...rest, [id]: { at, moved: Date.now() } } : rest
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
onMount(() => {
|
|
262
|
-
if (isServer()) return
|
|
263
|
-
const host = root?.() ?? document
|
|
264
|
-
let last = 0, waiting: ReturnType<typeof setTimeout> | undefined, next: CursorAt | null | undefined
|
|
265
|
-
const flush = () => {
|
|
266
|
-
waiting = undefined
|
|
267
|
-
if (next === undefined) return
|
|
268
|
-
last = performance.now()
|
|
269
|
-
send(next)
|
|
270
|
-
next = undefined
|
|
271
|
-
}
|
|
272
|
-
const put = (at: CursorAt | null) => {
|
|
273
|
-
next = at
|
|
274
|
-
const wait = gap - (performance.now() - last)
|
|
275
|
-
if (wait <= 0) flush()
|
|
276
|
-
else waiting ??= setTimeout(flush, wait)
|
|
277
|
-
}
|
|
278
|
-
const move = (e: Event) => {
|
|
279
|
-
const p = e as PointerEvent
|
|
280
|
-
put({ ...locate(p.target as Element, p.clientX, p.clientY, anchor), ...(style ? { style } : {}) })
|
|
281
|
-
}
|
|
282
|
-
const out = () => put(null)
|
|
283
|
-
let frame = 0
|
|
284
|
-
const moved = () => { frame ||= requestAnimationFrame(() => { frame = 0; layout.value++ }) }
|
|
285
|
-
host.addEventListener('pointermove', move, { passive: true })
|
|
286
|
-
document.documentElement.addEventListener('pointerleave', out)
|
|
287
|
-
addEventListener('scroll', moved, { passive: true, capture: true })
|
|
288
|
-
addEventListener('resize', moved)
|
|
289
|
-
const tick = setInterval(() => { clock.value = Date.now() }, 1000)
|
|
290
|
-
onCleanup(() => {
|
|
291
|
-
host.removeEventListener('pointermove', move)
|
|
292
|
-
document.documentElement.removeEventListener('pointerleave', out)
|
|
293
|
-
removeEventListener('scroll', moved, { capture: true })
|
|
294
|
-
removeEventListener('resize', moved)
|
|
295
|
-
clearInterval(tick)
|
|
296
|
-
clearTimeout(waiting)
|
|
297
|
-
cancelAnimationFrame(frame)
|
|
298
|
-
})
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
const list = derived(() => {
|
|
302
|
-
void layout.value
|
|
303
|
-
const now = clock.value || Date.now()
|
|
304
|
-
const peers = room.peers.value
|
|
305
|
-
const out: LiveCursor<S>[] = []
|
|
306
|
-
for (const [id, { at, moved }] of Object.entries(raw.value)) {
|
|
307
|
-
const spot = resolve(at, anchor)
|
|
308
|
-
if (spot) out.push({ id, peer: peers.find(p => p.id === id), at, ...spot, moved, idle: now - moved > idle })
|
|
309
|
-
}
|
|
310
|
-
return out
|
|
311
|
-
})
|
|
312
|
-
return { list, anchor }
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** A peer's colour, the same for them everywhere. */
|
|
316
|
-
export function hue(id: string): number {
|
|
317
|
-
let h = 0
|
|
318
|
-
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
|
319
|
-
return Math.abs(h) % 360
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ---- relay ----------------------------------------------------------------------------------------------------------
|
|
174
|
+
export { cursors, hue, locate, resolve } from './index/cursors'
|
|
175
|
+
export type { CursorOptions, Cursors, LiveCursor } from './index/cursors'
|
|
323
176
|
|
|
324
177
|
/** Something a peer did, named and validated by the server, and which peer did it. */
|
|
325
|
-
export interface RelayEvent<D> {
|
|
178
|
+
export interface RelayEvent<D> {
|
|
179
|
+
peer: string
|
|
180
|
+
kind: string
|
|
181
|
+
data: D
|
|
182
|
+
}
|
|
326
183
|
|
|
327
184
|
/** Something a peer did, delivered to a callback. Never a synthetic DOM event: the page decides what it means. */
|
|
328
185
|
export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' | 'send'>) {
|
|
329
186
|
const handlers = new Map<string, Set<(e: RelayEvent<unknown>) => void>>()
|
|
330
|
-
hear(conn, 'mp.relay', ({ id, kind, data }: { id: string; kind: string; data: unknown }) => {
|
|
187
|
+
hear(conn, 'mp.relay', ({ id, kind, data }: { id: string; kind: string; data: unknown }) => {
|
|
188
|
+
for (const f of handlers.get(kind) ?? []) f({ peer: id, kind, data })
|
|
189
|
+
})
|
|
331
190
|
return {
|
|
332
|
-
emit: <N extends keyof K & string>(kind: N, data: K[N]) =>
|
|
191
|
+
emit: <N extends keyof K & string>(kind: N, data: K[N]) =>
|
|
192
|
+
conn.send('mp.relay', { kind, data }),
|
|
333
193
|
on<N extends keyof K & string>(kind: N, fn: (e: RelayEvent<K[N]>) => void) {
|
|
334
194
|
const set = handlers.get(kind) ?? new Set()
|
|
335
195
|
handlers.set(kind, set)
|
|
@@ -342,17 +202,22 @@ export function relay<K extends Record<string, unknown>>(conn: Pick<Conn, 'on' |
|
|
|
342
202
|
}
|
|
343
203
|
}
|
|
344
204
|
|
|
345
|
-
// ---- synced scroll and hash: the box, not the DOM ------------------------------------------------------
|
|
346
|
-
|
|
347
205
|
/** One scroll position in a shared box, as a fraction, so different heights land on the same place. */
|
|
348
|
-
export function syncedScroll(
|
|
206
|
+
export function syncedScroll(
|
|
207
|
+
box: State<number>,
|
|
208
|
+
element: () => HTMLElement | Window = () => window,
|
|
209
|
+
) {
|
|
349
210
|
onMount(() => {
|
|
350
211
|
if (isServer()) return
|
|
351
212
|
const el = element()
|
|
352
213
|
const page = el === window
|
|
353
|
-
const max = () =>
|
|
214
|
+
const max = () =>
|
|
215
|
+
page
|
|
216
|
+
? document.documentElement.scrollHeight - innerHeight
|
|
217
|
+
: (el as HTMLElement).scrollHeight - (el as HTMLElement).clientHeight
|
|
354
218
|
const now = () => (page ? scrollY : (el as HTMLElement).scrollTop)
|
|
355
|
-
let held = 0,
|
|
219
|
+
let held = 0,
|
|
220
|
+
frame = 0
|
|
356
221
|
const on = () => {
|
|
357
222
|
if (performance.now() < held) return
|
|
358
223
|
frame ||= requestAnimationFrame(() => {
|
|
@@ -366,12 +231,15 @@ export function syncedScroll(box: State<number>, element: () => HTMLElement | Wi
|
|
|
366
231
|
el.removeEventListener('scroll', on)
|
|
367
232
|
cancelAnimationFrame(frame)
|
|
368
233
|
})
|
|
369
|
-
effect(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
234
|
+
effect(
|
|
235
|
+
() => box.value,
|
|
236
|
+
(f) => {
|
|
237
|
+
const top = f * max()
|
|
238
|
+
if (Math.abs(top - now()) < 2) return
|
|
239
|
+
held = performance.now() + 150 // what this write causes isn't the user scrolling
|
|
240
|
+
el.scrollTo({ top })
|
|
241
|
+
},
|
|
242
|
+
)
|
|
375
243
|
})
|
|
376
244
|
}
|
|
377
245
|
|
|
@@ -379,9 +247,16 @@ export function syncedScroll(box: State<number>, element: () => HTMLElement | Wi
|
|
|
379
247
|
export function syncedHash(box: State<string>) {
|
|
380
248
|
onMount(() => {
|
|
381
249
|
if (isServer()) return
|
|
382
|
-
const on = () => {
|
|
250
|
+
const on = () => {
|
|
251
|
+
box.value = location.hash
|
|
252
|
+
}
|
|
383
253
|
addEventListener('hashchange', on)
|
|
384
254
|
onCleanup(() => removeEventListener('hashchange', on))
|
|
385
|
-
effect(
|
|
255
|
+
effect(
|
|
256
|
+
() => box.value,
|
|
257
|
+
(h) => {
|
|
258
|
+
if (h && h !== location.hash) location.hash = h
|
|
259
|
+
},
|
|
260
|
+
)
|
|
386
261
|
})
|
|
387
262
|
}
|