@lincyaw/addon-session 0.0.0-stage → 1.0.0-rc.1

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.
@@ -0,0 +1,4 @@
1
+ export { createClassroomConnection } from './classroom-connection.ts'
2
+ export type { ClassroomConnection, ClassroomConnectionOptions } from './classroom-connection.ts'
3
+ export { useClassroomSession } from './composables.ts'
4
+ export { sessionPlugin } from './plugin.ts'
@@ -0,0 +1,75 @@
1
+ import type { Participant } from '@lincyaw/kernel'
2
+ import type { FollowMode } from '../domain/index.ts'
3
+
4
+ export interface Membership {
5
+ server: string
6
+ room: string
7
+ me: Participant
8
+ key: string
9
+ claim?: string
10
+ follow?: FollowMode
11
+ at?: number
12
+ }
13
+
14
+ export function tryStorage<T>(access: () => T): T | undefined {
15
+ try {
16
+ return access()
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
22
+ export interface MembershipStorageOptions {
23
+ /** The key prefix: one per deck. */
24
+ prefix: string
25
+ /** Where memberships are kept for every tab (localStorage). */
26
+ storage?: Storage
27
+ /** Where this tab keeps the room it is in (sessionStorage). */
28
+ tabStorage?: Storage
29
+ /** The classroom server memberships are kept for; others are not found. */
30
+ server: () => string | undefined
31
+ }
32
+
33
+ export interface MembershipStorage {
34
+ isUsable(value: Membership | null | undefined): value is Membership
35
+ find(room: string): Membership | undefined
36
+ /** The room this tab was last in. */
37
+ findInTab(): Membership | undefined
38
+ /** The presenter's membership this browser joined last, of `room` when given. */
39
+ findLastPresenterMembership(room?: string): Membership | undefined
40
+ remember(membership: Membership): void
41
+ forget(room: string): void
42
+ }
43
+
44
+ export function createMembershipStorage(options: MembershipStorageOptions): MembershipStorage {
45
+ const { prefix, storage, tabStorage } = options
46
+ const isUsable = (value: Membership | null | undefined): value is Membership =>
47
+ Boolean(value && value.server === options.server() && value.room && value.me?.id && value.key)
48
+ const find = (room: string) => tryStorage(() => {
49
+ const value = JSON.parse(storage?.getItem(`${prefix}:${room}`) ?? 'null') as Membership | null
50
+ return isUsable(value) ? value : undefined
51
+ })
52
+ return {
53
+ isUsable,
54
+ find,
55
+ findInTab() {
56
+ const room = tryStorage(() => tabStorage?.getItem(prefix))
57
+ return room ? find(room) : undefined
58
+ },
59
+ findLastPresenterMembership(room) {
60
+ const keys = tryStorage(() => Array.from({ length: storage?.length ?? 0 }, (_, i) => storage!.key(i) ?? '')) ?? []
61
+ const rooms = room ? [room] : keys.filter(key => key.startsWith(`${prefix}:`)).map(key => key.slice(prefix.length + 1))
62
+ return rooms.map(find)
63
+ .filter((value): value is Membership => value?.me.role === 'presenter' && value.claim !== undefined)
64
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0]
65
+ },
66
+ remember(membership) {
67
+ tryStorage(() => storage?.setItem(`${prefix}:${membership.room}`, JSON.stringify(membership)))
68
+ tryStorage(() => tabStorage?.setItem(prefix, membership.room))
69
+ },
70
+ forget(room) {
71
+ tryStorage(() => storage?.removeItem(`${prefix}:${room}`))
72
+ if (tryStorage(() => tabStorage?.getItem(prefix)) === room) tryStorage(() => tabStorage?.removeItem(prefix))
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,88 @@
1
+ import { currentLocale, NAVIGATION, pluginRoutePath, siteBase, SYNC_TRANSPORTS, TOOLBAR, serverClient } from '@lincyaw/host'
2
+ import { CLASSROOM_STORE, isRecord, PRESENTER_PANELS, SESSION } from '@lincyaw/kernel'
3
+ import type { PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
4
+ import { version } from '../../package.json'
5
+ import { localServer, MESSAGES, reachableServer, SESSION_ID, toSessionConfig } from '../domain/index.ts'
6
+ import type { LocalClassroom } from '../domain/index.ts'
7
+ import { createClassroomConnection } from './classroom-connection.ts'
8
+ import { tryStorage } from './membership-storage.ts'
9
+ import SessionPanel from './SessionPanel.tsx'
10
+ import SessionToolbar from './SessionToolbar.tsx'
11
+
12
+ let cleanup: Unsubscribe[] = []
13
+
14
+ const SERVER_WAIT_MS = 5000
15
+
16
+ async function fetchLocalServer(): Promise<{ server: string, params: Record<string, string> } | undefined> {
17
+ let client = serverClient()
18
+ const available = client.available || await new Promise<boolean>((resolve) => {
19
+ const timer = setTimeout(() => done(false), SERVER_WAIT_MS)
20
+ const poll = setInterval(() => { client = serverClient(); if (client.available) done(true) }, 50)
21
+ const stop = () => clearInterval(poll)
22
+ function done(ready: boolean) {
23
+ clearTimeout(timer)
24
+ stop()
25
+ resolve(ready)
26
+ }
27
+ })
28
+ if (!available) return undefined
29
+ const response = await client.fetch(SESSION_ID, 'classroom').catch(() => undefined)
30
+ const answer: unknown = response?.ok ? await response.json().catch(() => undefined) : undefined
31
+ if (!isRecord(answer) || typeof answer.path !== 'string' || typeof answer.document !== 'string') return undefined
32
+ // The server names its route from its own root; the page reaches it below the root it was served at.
33
+ const own = `/__presmium/${SESSION_ID}/`
34
+ const path = answer.path.startsWith(own) ? `${pluginRoutePath(client, SESSION_ID)}${answer.path.slice(own.length)}` : answer.path
35
+ return localServer({ ...answer, path } as unknown as LocalClassroom, location)
36
+ }
37
+
38
+ export const sessionPlugin: PluginDefinition = {
39
+ manifest: {
40
+ id: SESSION_ID,
41
+ name: { 'zh-CN': '课堂同步', en: 'Classroom Sync' },
42
+ version,
43
+ kernel: '^1.0.0',
44
+ provides: [SESSION, CLASSROOM_STORE],
45
+ requires: [NAVIGATION, TOOLBAR, PRESENTER_PANELS, SYNC_TRANSPORTS].map(token => ({ token, optional: true })),
46
+ capabilities: ['network'],
47
+ },
48
+ setup(ctx) {
49
+ const { server } = toSessionConfig(ctx.config)
50
+ const connection = createClassroomConnection({
51
+ server: server && reachableServer(server, location.hostname),
52
+ navigation: ctx.tryGet(NAVIGATION),
53
+ base: new URL(siteBase(), location.href).href,
54
+ storage: tryStorage(() => window.localStorage),
55
+ tabStorage: tryStorage(() => window.sessionStorage),
56
+ })
57
+ ctx.provide(SESSION, connection.session)
58
+ ctx.provide(CLASSROOM_STORE, connection.store)
59
+ const title = MESSAGES[currentLocale()].classroom
60
+ let disposed = false
61
+ const show = () => [
62
+ // The toolbar button also mounts the learner's badge, once per page (SessionOverlays).
63
+ ctx.tryGet(TOOLBAR)?.register({ id: SESSION_ID, title, component: SessionToolbar }),
64
+ ctx.tryGet(PRESENTER_PANELS)?.register({ id: SESSION_ID, title, component: SessionPanel, order: 10 }),
65
+ ]
66
+ cleanup = [connection.dispose, ctx.tryGet(SYNC_TRANSPORTS)?.add(connection.transport)].filter(stop => stop !== undefined)
67
+ if (server !== undefined) {
68
+ cleanup.push(...show().filter(stop => stop !== undefined))
69
+ void connection.resume(location.search)
70
+ return
71
+ }
72
+ void fetchLocalServer().then((local) => {
73
+ if (disposed) return
74
+ if (local) connection.setServer(local.server, local.params)
75
+ if (local || import.meta.env.DEV) cleanup.push(...show().filter(stop => stop !== undefined))
76
+ return connection.resume(location.search)
77
+ })
78
+ cleanup.push(() => {
79
+ disposed = true
80
+ })
81
+ },
82
+ dispose() {
83
+ cleanup.forEach(stop => stop())
84
+ cleanup = []
85
+ },
86
+ }
87
+
88
+ export default sessionPlugin
@@ -0,0 +1,71 @@
1
+ import type { SyncTransport } from '@lincyaw/host'
2
+ import type * as Y from 'yjs'
3
+ import { jsonClone } from '../domain/index.ts'
4
+ import { channel, observe, writeChannel } from './ydoc.ts'
5
+
6
+ interface Link {
7
+ receive(patch: Record<string, unknown>): void
8
+ state: Readonly<Record<string, unknown>>
9
+ }
10
+
11
+ export interface SyncChannelRoles {
12
+ /** This window takes the presenter's state of the channels. */
13
+ following(): boolean
14
+ /** This window writes its state of the channels for the others. */
15
+ publishing(): boolean
16
+ }
17
+
18
+ /** The host's sync channels (shared state) carried through the classroom document. */
19
+ export interface SyncChannels {
20
+ transport: SyncTransport
21
+ /** Observes the channels of the current document. */
22
+ rebind(): void
23
+ /** Gives a following window the presenter's state of every channel. */
24
+ deliver(): void
25
+ /** Writes this window's state of every channel. */
26
+ publishAll(): void
27
+ }
28
+
29
+ export function createSyncChannels(doc: () => Y.Doc, roles: SyncChannelRoles): SyncChannels {
30
+ const links = new Map<string, Link>()
31
+ let detach: (() => void)[] = []
32
+
33
+ function attach(name: string, link: Link): void {
34
+ const map = channel(doc(), name)
35
+ detach.push(observe(map, (event, transaction) => {
36
+ if (transaction.local || !roles.following()) return
37
+ link.receive(Object.fromEntries([...event.keysChanged].map(key => [key, jsonClone(map.get(key))])))
38
+ }))
39
+ }
40
+
41
+ return {
42
+ transport: {
43
+ connect(name, receive, state) {
44
+ const link: Link = { receive, state }
45
+ links.set(name, link)
46
+ attach(name, link)
47
+ const current = channel(doc(), name).toJSON()
48
+ if (roles.following() && Object.keys(current).length > 0) receive(current)
49
+ return (next, fromRemote) => {
50
+ link.state = next
51
+ if (!fromRemote && roles.publishing()) writeChannel(doc(), name, next)
52
+ }
53
+ },
54
+ },
55
+ rebind() {
56
+ detach.forEach(stop => stop())
57
+ detach = []
58
+ for (const [name, link] of links) attach(name, link)
59
+ },
60
+ deliver() {
61
+ if (!roles.following()) return
62
+ for (const [name, link] of links) {
63
+ const state = channel(doc(), name).toJSON()
64
+ if (Object.keys(state).length > 0) link.receive(state)
65
+ }
66
+ },
67
+ publishAll() {
68
+ for (const [name, link] of links) writeChannel(doc(), name, link.state)
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,41 @@
1
+ import type { Membership } from './membership-storage.ts'
2
+
3
+ type TabMessage =
4
+ | { type: 'who' }
5
+ | { type: 'presenter', membership: Membership }
6
+ | { type: 'left', room: string }
7
+
8
+ export interface TabHandlers {
9
+ /** Another tab asks which tab of this browser presents. */
10
+ asked(): void
11
+ /** A tab of this browser presents with `membership`. */
12
+ presenting(membership: Membership): void
13
+ /** The tab that presented `room` left it. */
14
+ left(room: string): void
15
+ }
16
+
17
+ /** The other tabs of this browser on the same deck: a presenter's other tabs watch the classroom as it does. */
18
+ export interface TabMirror {
19
+ ask(): void
20
+ announce(membership: Membership): void
21
+ leave(room: string): void
22
+ close(): void
23
+ }
24
+
25
+ export function connectTabs(name: string, handlers: TabHandlers): TabMirror {
26
+ const tabs = typeof BroadcastChannel === 'undefined' ? undefined : new BroadcastChannel(name)
27
+ const tell = (message: TabMessage) => tabs?.postMessage(message)
28
+ if (tabs) {
29
+ tabs.onmessage = ({ data }: MessageEvent<TabMessage>) => {
30
+ if (data.type === 'who') handlers.asked()
31
+ else if (data.type === 'presenter') handlers.presenting(data.membership)
32
+ else if (data.type === 'left') handlers.left(data.room)
33
+ }
34
+ }
35
+ return {
36
+ ask: () => tell({ type: 'who' }),
37
+ announce: membership => tell({ type: 'presenter', membership }),
38
+ leave: room => tell({ type: 'left', room }),
39
+ close: () => tabs?.close(),
40
+ }
41
+ }
@@ -0,0 +1,6 @@
1
+ import type { ViewerPluginContext } from '@lincyaw/viewer'
2
+ import SessionBadge from './SessionBadge.tsx'
3
+
4
+ export default function sessionViewerPlugin({ registerLayer }: ViewerPluginContext): void {
5
+ registerLayer({ id: 'presmium.session.badge', component: SessionBadge, scope: 'viewport' })
6
+ }
@@ -0,0 +1,52 @@
1
+ import type { Participant } from '@lincyaw/kernel'
2
+ import type * as Y from 'yjs'
3
+ import { changedEntries, clonePosition, endRecord, samePosition, startRecord } from '../domain/index.ts'
4
+ import type { Position, SessionRecord } from '../domain/index.ts'
5
+
6
+ const SESSION = 'session'
7
+
8
+ export function observe(map: Y.Map<unknown>, handler: (event: Y.YMapEvent<unknown>, transaction: Y.Transaction) => void): () => void {
9
+ map.observe(handler)
10
+ return () => map.unobserve(handler)
11
+ }
12
+
13
+ export function sessionMap(doc: Y.Doc): Y.Map<unknown> {
14
+ return doc.getMap(SESSION)
15
+ }
16
+
17
+ export function sessionRecordOf(doc: Y.Doc): SessionRecord {
18
+ return sessionMap(doc).toJSON() as SessionRecord
19
+ }
20
+
21
+ function write(doc: Y.Doc, map: Y.Map<unknown>, entries: Iterable<[string, unknown]>): void {
22
+ doc.transact(() => {
23
+ for (const [key, value] of entries) map.set(key, value)
24
+ })
25
+ }
26
+
27
+ export function writeSessionStart(doc: Y.Doc, presenter: Participant, now: number): void {
28
+ write(doc, sessionMap(doc), Object.entries(startRecord(presenter, now)))
29
+ }
30
+
31
+ export function writeSessionEnd(doc: Y.Doc, now: number): void {
32
+ write(doc, sessionMap(doc), Object.entries(endRecord(now)))
33
+ }
34
+
35
+ export function publishCursor(doc: Y.Doc, position: Position): void {
36
+ const session = sessionMap(doc)
37
+ const cursor = clonePosition(position)
38
+ if (!samePosition(session.get('cursor') as Position | undefined, cursor)) session.set('cursor', cursor)
39
+ }
40
+
41
+ export function collection(doc: Y.Doc, name: string): Y.Map<unknown> {
42
+ return doc.getMap(`store:${name}`)
43
+ }
44
+
45
+ export function channel(doc: Y.Doc, name: string): Y.Map<unknown> {
46
+ return doc.getMap(`sync:${name}`)
47
+ }
48
+
49
+ export function writeChannel(doc: Y.Doc, name: string, state: Readonly<Record<string, unknown>>): void {
50
+ const map = channel(doc, name)
51
+ write(doc, map, changedEntries(key => map.get(key), state))
52
+ }
@@ -0,0 +1,47 @@
1
+ import type { ClassroomStore } from '@lincyaw/kernel'
2
+ import type * as Y from 'yjs'
3
+ import { jsonClone } from '../domain/index.ts'
4
+ import { collection, observe } from './ydoc.ts'
5
+
6
+ export interface YjsStore {
7
+ store: ClassroomStore
8
+ /** Observes the collections of the current document and tells every listener. */
9
+ rebind(): void
10
+ }
11
+
12
+ export function createYjsStore(doc: () => Y.Doc, checkWrite: () => void): YjsStore {
13
+ const listeners = new Map<string, Set<() => void>>()
14
+ let detach: (() => void)[] = []
15
+ const attach = (name: string) => detach.push(observe(collection(doc(), name), () => listeners.get(name)?.forEach(listener => listener())))
16
+ return {
17
+ store: {
18
+ list: <T>(name: string) => [...collection(doc(), name).entries()].map(([id, value]) => ({ id, value: value as T })),
19
+ get: <T>(name: string, id: string) => collection(doc(), name).get(id) as T | undefined,
20
+ put(name, id, value) {
21
+ checkWrite()
22
+ collection(doc(), name).set(id, jsonClone(value))
23
+ },
24
+ remove(name, id) {
25
+ checkWrite()
26
+ collection(doc(), name).delete(id)
27
+ },
28
+ subscribe(name, listener) {
29
+ let set = listeners.get(name)
30
+ if (!set) {
31
+ listeners.set(name, set = new Set())
32
+ attach(name)
33
+ }
34
+ set.add(listener)
35
+ return () => {
36
+ set.delete(listener)
37
+ }
38
+ },
39
+ },
40
+ rebind() {
41
+ detach.forEach(stop => stop())
42
+ detach = []
43
+ for (const name of listeners.keys()) attach(name)
44
+ listeners.forEach(set => set.forEach(listener => listener()))
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,38 @@
1
+ export const SESSION_ID = 'presmium.session'
2
+
3
+ export const SYNC_PATH = 'sync'
4
+
5
+ export interface LocalClassroom {
6
+ path: string
7
+ document: string
8
+ }
9
+
10
+ export function localServer(answer: LocalClassroom, page: { protocol: string, host: string }): { server: string, params: Record<string, string> } {
11
+ return { server: `${page.protocol === 'https:' ? 'wss:' : 'ws:'}//${page.host}${answer.path}`, params: { document: answer.document } }
12
+ }
13
+
14
+ export interface SessionConfig {
15
+ server?: string
16
+ }
17
+
18
+ export function toSessionConfig(config: Record<string, unknown>): SessionConfig {
19
+ const server = typeof config.server === 'string' ? config.server.trim() : ''
20
+ if (!server) return {}
21
+ return { server: server.replace(/^http(s?):\/\//, 'ws$1://').replace(/\/+$/, '') }
22
+ }
23
+
24
+ export function isLoopback(hostname: string): boolean {
25
+ return /^(?:localhost|127(?:\.\d{1,3}){3}|\[?::1\]?)$/i.test(hostname)
26
+ }
27
+
28
+ export function reachableServer(server: string, pageHostname: string): string {
29
+ let url: URL
30
+ try {
31
+ url = new URL(server)
32
+ } catch {
33
+ return server
34
+ }
35
+ if (!pageHostname || !isLoopback(url.hostname) || isLoopback(pageHostname)) return server
36
+ url.hostname = pageHostname
37
+ return url.href.replace(/\/+$/, '')
38
+ }
@@ -0,0 +1,20 @@
1
+ export { isLoopback, localServer, reachableServer, SESSION_ID, SYNC_PATH, toSessionConfig } from './config.ts'
2
+ export type { LocalClassroom, SessionConfig } from './config.ts'
3
+ export { archiveUrl, isLoopbackLink, joinRequest, joinUrl, readableString } from './links.ts'
4
+ export type { JoinRequest } from './links.ts'
5
+ export { MESSAGES } from './messages.ts'
6
+ export type { SessionMessages } from './messages.ts'
7
+ export {
8
+ changedEntries,
9
+ clonePosition,
10
+ endRecord,
11
+ followTarget,
12
+ jsonClone,
13
+ participantsOf,
14
+ samePosition,
15
+ sessionStatus,
16
+ startRecord,
17
+ } from './session-record.ts'
18
+ export type { FollowMode, Position, SessionRecord } from './session-record.ts'
19
+ export { isClassroomSession, shortStatus, statusText } from './session-service.ts'
20
+ export type { ClassroomSession, ClassroomSnapshot } from './session-service.ts'
@@ -0,0 +1,41 @@
1
+ import { isLoopback } from './config.ts'
2
+
3
+ const ROOM = /^[\w.-]{1,128}$/
4
+ const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'
5
+
6
+ export interface JoinRequest {
7
+ room: string
8
+ name?: string
9
+ }
10
+
11
+ export function joinRequest(search: string): JoinRequest | undefined {
12
+ const params = new URLSearchParams(search)
13
+ const room = params.get('room')?.trim()
14
+ if (!room || !ROOM.test(room)) return undefined
15
+ const name = params.get('name')?.trim()
16
+ return name ? { room, name } : { room }
17
+ }
18
+
19
+ export function joinUrl(base: string, room: string): string {
20
+ const url = new URL(base)
21
+ url.search = `?room=${encodeURIComponent(room)}`
22
+ url.hash = ''
23
+ return url.href
24
+ }
25
+
26
+ export function isLoopbackLink(link: string): boolean {
27
+ try {
28
+ return isLoopback(new URL(link).hostname)
29
+ } catch {
30
+ return false
31
+ }
32
+ }
33
+
34
+ export function readableString(random: Uint8Array): string {
35
+ return [...random].map(byte => ALPHABET[byte % ALPHABET.length]).join('')
36
+ }
37
+
38
+ export function archiveUrl(server: string, room: string, params: Record<string, string> = {}): string {
39
+ const query = new URLSearchParams(params).toString()
40
+ return `${server.replace(/^ws(s?):/, 'http$1:')}/archive/${encodeURIComponent(room)}${query ? `?${query}` : ''}`
41
+ }
@@ -0,0 +1,137 @@
1
+ import type { Locale } from '@lincyaw/kernel'
2
+
3
+ const zh = {
4
+ classroom: '课堂',
5
+ status: {
6
+ noServer: '未连接:文稿没有配置课堂服务',
7
+ failed: (error: string) => `未连接:${error}`,
8
+ invited: (room: string) => `未连接:受邀加入课堂 ${room}`,
9
+ idle: '未连接:尚未加入课堂',
10
+ archived: (file: string) => `课堂已结束并归档到 ${file}`,
11
+ ended: '课堂已结束',
12
+ connecting: '正在连接课堂服务…',
13
+ presenting: '课堂进行中,你是主讲',
14
+ following: '课堂进行中,正在跟随主讲',
15
+ browsing: '课堂进行中,自主浏览',
16
+ },
17
+ short: {
18
+ noServer: '未连接',
19
+ idle: '未加入',
20
+ archived: '已归档',
21
+ ended: '已结束',
22
+ connecting: '正在连接…',
23
+ presenter: '主讲',
24
+ follow: '跟随',
25
+ free: '自主',
26
+ },
27
+ hint: { beforeCommand: '文稿照常放映与阅读。要开课,启动 ', beforeKey: ',并在文稿 frontmatter 中设置 ', end: '。' },
28
+ nickname: '昵称',
29
+ create: '创建课堂',
30
+ join: '加入课堂',
31
+ joinRoom: (room: string) => `加入课堂 ${room}`,
32
+ role: { presenter: '(主讲)', learner: '(学习者)' },
33
+ joinLink: '加入链接:',
34
+ qr: '加入课堂的二维码',
35
+ participants: '参与者',
36
+ participantCount: (n: number) => `参与者(${n})`,
37
+ end: '结束课堂',
38
+ archive: '归档课堂',
39
+ archiveTitle: '把课堂记录写入文稿目录,并从课堂服务中释放',
40
+ leaveClassroom: '离开课堂',
41
+ leave: '离开',
42
+ close: '关闭',
43
+ badge: '课堂状态',
44
+ followingPresenter: '正在跟随主讲',
45
+ browseAlone: '自主浏览',
46
+ browsingAlone: '自主浏览中',
47
+ presenterAt: (slide: number, step: number) => `主讲在第 ${slide} 页第 ${step} 步`,
48
+ backToPresenter: '回到主讲进度',
49
+ rejoin: (room: string) => `以主讲身份回到课堂 ${room}`,
50
+ rejoinTitle: '这个浏览器创建过这个课堂,可以继续主讲、结束与归档它',
51
+ loopbackLink: '学习者的设备打不开这个地址,请用局域网地址打开文稿',
52
+ refused: '课堂服务拒绝了你刚才的写入(例如超出长度的提问),页面已按课堂的记录重新同步',
53
+ defaultName: { presenter: '主讲', learner: '学习者' },
54
+ errors: {
55
+ noServer: '文稿没有配置课堂服务',
56
+ ended: '课堂已结束,不再接受写入',
57
+ archived: '课堂已归档',
58
+ closed: (code: number) => `课堂服务关闭了连接(${code})`,
59
+ unreachable: (server: string) => `无法连接课堂服务 ${server}`,
60
+ onlyPresenterEnds: '只有主讲可以结束课堂',
61
+ onlyPresenterArchives: '只有主讲可以归档课堂',
62
+ notCreator: '只有创建课堂的主讲可以归档',
63
+ noRoom: '课堂服务中没有这个课堂',
64
+ alreadyArchived: '课堂已经归档',
65
+ archiveFailed: (status: number) => `无法归档课堂(${status})`,
66
+ },
67
+ }
68
+
69
+ export type SessionMessages = typeof zh
70
+
71
+ const en: SessionMessages = {
72
+ classroom: 'Classroom',
73
+ status: {
74
+ noServer: 'Not connected: the document has no classroom server',
75
+ failed: error => `Not connected: ${error}`,
76
+ invited: room => `Not connected: invited to classroom ${room}`,
77
+ idle: 'Not connected: not in a classroom',
78
+ archived: file => `The classroom has ended and is archived to ${file}`,
79
+ ended: 'The classroom has ended',
80
+ connecting: 'Connecting to the classroom server…',
81
+ presenting: 'Classroom in progress; you are presenting',
82
+ following: 'Classroom in progress; following the presenter',
83
+ browsing: 'Classroom in progress; browsing alone',
84
+ },
85
+ short: {
86
+ noServer: 'Not connected',
87
+ idle: 'Not joined',
88
+ archived: 'Archived',
89
+ ended: 'Ended',
90
+ connecting: 'Connecting…',
91
+ presenter: 'Presenting',
92
+ follow: 'Following',
93
+ free: 'Browsing',
94
+ },
95
+ hint: { beforeCommand: 'The document presents and reads as usual. To hold a classroom, start ', beforeKey: ' and set ', end: ' in the document frontmatter.' },
96
+ nickname: 'Nickname',
97
+ create: 'Create Classroom',
98
+ join: 'Join Classroom',
99
+ joinRoom: room => `Join Classroom ${room}`,
100
+ role: { presenter: ' (presenter)', learner: ' (learner)' },
101
+ joinLink: 'Join link: ',
102
+ qr: 'QR code to join the classroom',
103
+ participants: 'Participants',
104
+ participantCount: n => `Participants (${n})`,
105
+ end: 'End Classroom',
106
+ archive: 'Archive Classroom',
107
+ archiveTitle: 'Write the classroom record to the document folder and free it on the classroom server',
108
+ leaveClassroom: 'Leave Classroom',
109
+ leave: 'Leave',
110
+ close: 'Close',
111
+ badge: 'Classroom status',
112
+ followingPresenter: 'Following the presenter',
113
+ browseAlone: 'Browse Alone',
114
+ browsingAlone: 'Browsing alone',
115
+ presenterAt: (slide, step) => `The presenter is at page ${slide}, step ${step}`,
116
+ backToPresenter: 'Back to Presenter',
117
+ rejoin: room => `Rejoin Classroom ${room} as Presenter`,
118
+ rejoinTitle: 'This browser created the classroom: go on presenting it, end it, and archive it',
119
+ loopbackLink: 'Learners’ devices can’t open this address; open the document at its address on the local network',
120
+ refused: 'The classroom server refused your last write (such as a question over the length limit); the page has synced again with the classroom',
121
+ defaultName: { presenter: 'Presenter', learner: 'Learner' },
122
+ errors: {
123
+ noServer: 'The document has no classroom server',
124
+ ended: 'The classroom has ended and takes no more writes',
125
+ archived: 'The classroom has been archived',
126
+ closed: code => `The classroom server closed the connection (${code})`,
127
+ unreachable: server => `Couldn’t reach the classroom server ${server}`,
128
+ onlyPresenterEnds: 'Only the presenter can end the classroom',
129
+ onlyPresenterArchives: 'Only the presenter can archive the classroom',
130
+ notCreator: 'Only the presenter who created the classroom can archive it',
131
+ noRoom: 'The classroom server has no such classroom',
132
+ alreadyArchived: 'The classroom is already archived',
133
+ archiveFailed: status => `Couldn’t archive the classroom (${status})`,
134
+ },
135
+ }
136
+
137
+ export const MESSAGES: Record<Locale, SessionMessages> = { 'zh-CN': zh, en }