@atom.io/template-react-realtime-text-editor 0.0.11 → 0.0.13
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/CHANGELOG.md +25 -0
- package/INNOVATIONS.md +242 -113
- package/README.md +75 -39
- package/eslint.config.ts +1 -1
- package/index.html +1 -0
- package/node/http-server.ts +81 -0
- package/node/server.ts +12 -60
- package/node/service.ts +310 -0
- package/package.json +30 -21
- package/playwright.config.ts +34 -0
- package/src/LexicalMarkdownEditor.css +88 -0
- package/src/LexicalMarkdownEditor.module.css +8 -0
- package/src/LexicalMarkdownEditor.tsx +43 -0
- package/src/MarkdownWorkspace.module.css +154 -98
- package/src/MarkdownWorkspace.tsx +163 -296
- package/src/collaboration-client.ts +238 -0
- package/src/document-domain.ts +169 -0
- package/src/globals.css +9 -2
- package/src/incremental-markdown.ts +279 -0
- package/src/main.tsx +30 -9
- package/src/protocol.ts +35 -0
- package/src/virtualization.ts +53 -0
- package/src/workspace-state.ts +275 -0
- package/tests/browser/editor-conformance.spec.ts +806 -0
- package/tests/collaboration.test.tsx +465 -213
- package/tests/incremental-markdown.test.tsx +101 -0
- package/tsconfig.app.json +1 -1
- package/tsconfig.node.json +8 -2
- package/tsconfig.test.json +1 -1
- package/vite.config.ts +13 -9
- package/node/mosaic-atom.ts +0 -88
- package/src/collaboration/mosaic.ts +0 -46
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createServer } from "node:http"
|
|
2
|
+
|
|
3
|
+
import { Server } from "socket.io"
|
|
4
|
+
|
|
5
|
+
import { identityById } from "../src/identities.ts"
|
|
6
|
+
import {
|
|
7
|
+
createMarkdownDocumentService,
|
|
8
|
+
type MarkdownDocumentService,
|
|
9
|
+
} from "./service.ts"
|
|
10
|
+
|
|
11
|
+
export type MarkdownCollaborationHttpServer = {
|
|
12
|
+
close(): Promise<void>
|
|
13
|
+
readonly collaboration: MarkdownDocumentService
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function createMarkdownCollaborationHttpServer(options: {
|
|
17
|
+
readonly collaboration?: MarkdownDocumentService
|
|
18
|
+
readonly port: number
|
|
19
|
+
}): Promise<MarkdownCollaborationHttpServer> {
|
|
20
|
+
const collaboration =
|
|
21
|
+
options.collaboration ?? (await createMarkdownDocumentService())
|
|
22
|
+
const httpServer = createServer((request, response) => {
|
|
23
|
+
if (request.url === `/health`) {
|
|
24
|
+
response.writeHead(200, { "content-type": `application/json` })
|
|
25
|
+
response.end(
|
|
26
|
+
JSON.stringify({ ok: true, revision: collaboration.revision }),
|
|
27
|
+
)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
response.writeHead(404)
|
|
31
|
+
response.end()
|
|
32
|
+
})
|
|
33
|
+
const socketServer = new Server(httpServer)
|
|
34
|
+
|
|
35
|
+
socketServer.on(`connection`, (socket) => {
|
|
36
|
+
const requestedActor =
|
|
37
|
+
typeof socket.handshake.auth.actor === `string`
|
|
38
|
+
? socket.handshake.auth.actor
|
|
39
|
+
: undefined
|
|
40
|
+
const session =
|
|
41
|
+
typeof socket.handshake.auth.session === `string`
|
|
42
|
+
? socket.handshake.auth.session
|
|
43
|
+
: ``
|
|
44
|
+
const identity = identityById(requestedActor)
|
|
45
|
+
if (
|
|
46
|
+
identity.id !== requestedActor ||
|
|
47
|
+
session.length === 0 ||
|
|
48
|
+
session.includes(`::`)
|
|
49
|
+
) {
|
|
50
|
+
socket.disconnect(true)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
void collaboration.bindSocket({ actor: identity.id, session, socket })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
await new Promise<void>((resolve, reject) => {
|
|
57
|
+
const onError = (error: Error): void => {
|
|
58
|
+
reject(error)
|
|
59
|
+
}
|
|
60
|
+
httpServer.once(`error`, onError)
|
|
61
|
+
httpServer.listen(options.port, `127.0.0.1`, () => {
|
|
62
|
+
httpServer.off(`error`, onError)
|
|
63
|
+
resolve()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
let closed = false
|
|
68
|
+
return {
|
|
69
|
+
async close() {
|
|
70
|
+
if (closed) return
|
|
71
|
+
closed = true
|
|
72
|
+
await new Promise<void>((resolve) =>
|
|
73
|
+
socketServer.close(() => {
|
|
74
|
+
resolve()
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
collaboration[Symbol.dispose]()
|
|
78
|
+
},
|
|
79
|
+
collaboration,
|
|
80
|
+
}
|
|
81
|
+
}
|
package/node/server.ts
CHANGED
|
@@ -1,67 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createMarkdownCollaborationHttpServer } from "./http-server.ts"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const silo = new Silo({
|
|
14
|
-
name: `markdown-collaboration-server`,
|
|
15
|
-
lifespan: `immortal`,
|
|
16
|
-
isProduction: process.env.NODE_ENV === `production`,
|
|
17
|
-
})
|
|
18
|
-
const httpServer = createServer((request, response) => {
|
|
19
|
-
if (request.url === `/health`) {
|
|
20
|
-
response.writeHead(200, { "content-type": `application/json` })
|
|
21
|
-
response.end(JSON.stringify({ ok: true }))
|
|
22
|
-
return
|
|
23
|
-
}
|
|
24
|
-
response.writeHead(404)
|
|
25
|
-
response.end()
|
|
26
|
-
})
|
|
27
|
-
const socketServer = new Server(httpServer)
|
|
28
|
-
|
|
29
|
-
const disposeRealtime = realtime(
|
|
30
|
-
socketServer,
|
|
31
|
-
(handshake) => {
|
|
32
|
-
const requestedId =
|
|
33
|
-
typeof handshake.auth.userId === `string`
|
|
34
|
-
? handshake.auth.userId
|
|
35
|
-
: undefined
|
|
36
|
-
const sessionId =
|
|
37
|
-
typeof handshake.auth.sessionId === `string`
|
|
38
|
-
? handshake.auth.sessionId
|
|
39
|
-
: undefined
|
|
40
|
-
if (!sessionId || sessionId.includes(`::`)) {
|
|
41
|
-
return new Error(`A valid Mosaic session is required.`)
|
|
42
|
-
}
|
|
43
|
-
const identity = identityById(requestedId)
|
|
44
|
-
return `user::${identity.id}::${sessionId}` satisfies UserKey
|
|
45
|
-
},
|
|
46
|
-
({ socket, consumer }) => {
|
|
47
|
-
const [, actor, session] = consumer.split(`::`)
|
|
48
|
-
if (!actor || !session) {
|
|
49
|
-
throw new Error(`Malformed Mosaic user session.`)
|
|
50
|
-
}
|
|
51
|
-
return mosaic.connect({ actor, session, socket })
|
|
52
|
-
},
|
|
53
|
-
silo.store,
|
|
3
|
+
const portArgument = process.argv.indexOf(`--port`)
|
|
4
|
+
const configuredPort =
|
|
5
|
+
portArgument === -1 ? process.env.PORT : process.argv[portArgument + 1]
|
|
6
|
+
const PORT = Number.parseInt(configuredPort ?? `3000`, 10)
|
|
7
|
+
if (!Number.isSafeInteger(PORT) || PORT < 1 || PORT > 65_535) {
|
|
8
|
+
throw new Error(`PORT must be an integer between 1 and 65535.`)
|
|
9
|
+
}
|
|
10
|
+
const server = await createMarkdownCollaborationHttpServer({ port: PORT })
|
|
11
|
+
console.log(
|
|
12
|
+
`Markdown collaboration server listening on http://localhost:${PORT}`,
|
|
54
13
|
)
|
|
55
14
|
|
|
56
|
-
httpServer.listen(PORT, () => {
|
|
57
|
-
console.log(
|
|
58
|
-
`Markdown collaboration server listening on http://localhost:${PORT}`,
|
|
59
|
-
)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
15
|
const shutdown = async (): Promise<void> => {
|
|
63
|
-
await
|
|
64
|
-
await mosaic.dispose()
|
|
16
|
+
await server.close()
|
|
65
17
|
}
|
|
66
18
|
process.once(`SIGINT`, () => void shutdown())
|
|
67
19
|
process.once(`SIGTERM`, () => void shutdown())
|
package/node/service.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { Silo } from "atom.io"
|
|
2
|
+
import type {
|
|
3
|
+
MosaicAcceptedDomainBatchEnvelope,
|
|
4
|
+
MosaicTextIndexLookup,
|
|
5
|
+
MosaicTextIndexRange,
|
|
6
|
+
MosaicTextIndexSummary,
|
|
7
|
+
MosaicTextRelativePosition,
|
|
8
|
+
MosaicTextSnapshot,
|
|
9
|
+
} from "@atom.io/realtime"
|
|
10
|
+
import {
|
|
11
|
+
bindMosaicDomainHistoryServerSocket,
|
|
12
|
+
bindMosaicDomainPresenceServerSocket,
|
|
13
|
+
bindMosaicDomainResidencyServerSocket,
|
|
14
|
+
createMosaicDomainBatchServer,
|
|
15
|
+
createMosaicDomainHistoryCoordinator,
|
|
16
|
+
createMosaicDomainPresenceServer,
|
|
17
|
+
createMosaicDomainResidencyServer,
|
|
18
|
+
createMosaicTextDocumentCoordinator,
|
|
19
|
+
InMemoryMosaicDomainCheckpointStorage,
|
|
20
|
+
type MosaicDomainCheckpointStorageAdapter,
|
|
21
|
+
type MosaicDomainHistoryConnection,
|
|
22
|
+
type MosaicTextDocumentInstrumentation,
|
|
23
|
+
type MosaicTextDocumentRangeInspection,
|
|
24
|
+
} from "@atom.io/realtime/server"
|
|
25
|
+
import type { Socket } from "socket.io"
|
|
26
|
+
import { z } from "zod"
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
activateMarkdownDocumentDomain,
|
|
30
|
+
MARKDOWN_INDEX_OPTIONS,
|
|
31
|
+
markdownIndexMemberModel,
|
|
32
|
+
markdownIndexRootModel,
|
|
33
|
+
markdownSourceAtom,
|
|
34
|
+
markdownSourceModel,
|
|
35
|
+
type MarkdownDocumentDomain,
|
|
36
|
+
type MarkdownText,
|
|
37
|
+
} from "../src/document-domain.ts"
|
|
38
|
+
import { INITIAL_MARKDOWN } from "../src/initial-markdown.ts"
|
|
39
|
+
import {
|
|
40
|
+
MARKDOWN_EVENTS,
|
|
41
|
+
type MarkdownAcknowledgement,
|
|
42
|
+
type MarkdownCommand,
|
|
43
|
+
} from "../src/protocol.ts"
|
|
44
|
+
|
|
45
|
+
export type MarkdownDocumentInstrumentation =
|
|
46
|
+
MosaicTextDocumentInstrumentation & {
|
|
47
|
+
readonly activeFullDocumentModels: 1
|
|
48
|
+
readonly maximumFullDocumentModels: 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type MarkdownDocumentRangeInspection = MosaicTextDocumentRangeInspection
|
|
52
|
+
|
|
53
|
+
export type MarkdownDocumentService = Disposable & {
|
|
54
|
+
bindSocket(options: {
|
|
55
|
+
readonly actor: string
|
|
56
|
+
readonly session: string
|
|
57
|
+
readonly socket: Socket
|
|
58
|
+
}): Promise<() => Promise<void>>
|
|
59
|
+
command(identity: {
|
|
60
|
+
readonly actor: string
|
|
61
|
+
readonly command: MarkdownCommand
|
|
62
|
+
readonly session: string
|
|
63
|
+
}): Promise<MosaicAcceptedDomainBatchEnvelope>
|
|
64
|
+
readonly domain: MarkdownDocumentDomain
|
|
65
|
+
history(identity: {
|
|
66
|
+
readonly actor: string
|
|
67
|
+
readonly session: string
|
|
68
|
+
}): MosaicDomainHistoryConnection
|
|
69
|
+
readonly instrumentation: MarkdownDocumentInstrumentation
|
|
70
|
+
readonly indexSummary: MosaicTextIndexSummary | null
|
|
71
|
+
inspectRange(
|
|
72
|
+
range: MosaicTextIndexRange,
|
|
73
|
+
): Promise<MarkdownDocumentRangeInspection>
|
|
74
|
+
readonly length: number
|
|
75
|
+
materialize(): string
|
|
76
|
+
positionAtOffset(offset: number): Promise<MosaicTextIndexLookup>
|
|
77
|
+
resolvePosition(position: MosaicTextRelativePosition): Promise<number>
|
|
78
|
+
readonly revision: number
|
|
79
|
+
sourceSnapshot(): MosaicTextSnapshot
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function createMarkdownDocumentService(
|
|
83
|
+
options: {
|
|
84
|
+
readonly authorizeImport?: (identity: {
|
|
85
|
+
readonly actor: string
|
|
86
|
+
readonly session: string
|
|
87
|
+
}) => boolean | Promise<boolean>
|
|
88
|
+
readonly initialText?: string
|
|
89
|
+
readonly presenceTtlMs?: number
|
|
90
|
+
readonly silo?: Silo
|
|
91
|
+
readonly storage?: MosaicDomainCheckpointStorageAdapter
|
|
92
|
+
} = {},
|
|
93
|
+
): Promise<MarkdownDocumentService> {
|
|
94
|
+
let bootstrapText = options.initialText ?? INITIAL_MARKDOWN
|
|
95
|
+
const silo =
|
|
96
|
+
options.silo ??
|
|
97
|
+
new Silo({
|
|
98
|
+
isProduction: process.env.NODE_ENV === `production`,
|
|
99
|
+
lifespan: `ephemeral`,
|
|
100
|
+
name: `markdown-document-server`,
|
|
101
|
+
})
|
|
102
|
+
const domain = await activateMarkdownDocumentDomain({ silo })
|
|
103
|
+
const storage = options.storage ?? new InMemoryMosaicDomainCheckpointStorage()
|
|
104
|
+
const batches = createMosaicDomainBatchServer({
|
|
105
|
+
domain,
|
|
106
|
+
limits: {
|
|
107
|
+
maxBytes: 192 * 1024 * 1024,
|
|
108
|
+
maxMembers: 65_536,
|
|
109
|
+
maxOperations: 65_536,
|
|
110
|
+
},
|
|
111
|
+
storage,
|
|
112
|
+
})
|
|
113
|
+
const source = (): InstanceType<typeof MarkdownText> =>
|
|
114
|
+
silo.getState(markdownSourceAtom) as InstanceType<typeof MarkdownText>
|
|
115
|
+
const document = await createMosaicTextDocumentCoordinator({
|
|
116
|
+
authorizeImport:
|
|
117
|
+
options.authorizeImport ??
|
|
118
|
+
(({ actor, session }) => actor === `ada` || session === `bootstrap`),
|
|
119
|
+
batches,
|
|
120
|
+
domain,
|
|
121
|
+
index: {
|
|
122
|
+
memberAddress: (id) => domain.address(`indexMembers`, id),
|
|
123
|
+
memberModel: markdownIndexMemberModel,
|
|
124
|
+
options: MARKDOWN_INDEX_OPTIONS,
|
|
125
|
+
rootAddress: domain.address(`indexRoot`),
|
|
126
|
+
rootModel: markdownIndexRootModel,
|
|
127
|
+
},
|
|
128
|
+
serviceIdentity: { actor: `markdown-service`, session: `markdown-service` },
|
|
129
|
+
source: {
|
|
130
|
+
address: domain.address(`source`),
|
|
131
|
+
model: markdownSourceModel,
|
|
132
|
+
read: source,
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
const history = createMosaicDomainHistoryCoordinator({
|
|
136
|
+
batches,
|
|
137
|
+
completeCompensation: document.completeCompensation,
|
|
138
|
+
domain,
|
|
139
|
+
limits: { undoStepsPerActor: 100 },
|
|
140
|
+
storage,
|
|
141
|
+
})
|
|
142
|
+
const residency = createMosaicDomainResidencyServer({
|
|
143
|
+
batches,
|
|
144
|
+
domain,
|
|
145
|
+
maxResidentMembers: 128,
|
|
146
|
+
range: {
|
|
147
|
+
resolve: ({ limit, member, range }) =>
|
|
148
|
+
member === `indexMembers`
|
|
149
|
+
? document.resolveRange(range, limit)
|
|
150
|
+
: Promise.resolve([]),
|
|
151
|
+
schema: z
|
|
152
|
+
.object({
|
|
153
|
+
end: z.number().int().nonnegative(),
|
|
154
|
+
kind: z.literal(`utf16-range`),
|
|
155
|
+
start: z.number().int().nonnegative(),
|
|
156
|
+
})
|
|
157
|
+
.strict()
|
|
158
|
+
.refine(({ end, start }) => end >= start),
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
const presence = createMosaicDomainPresenceServer({
|
|
162
|
+
domain,
|
|
163
|
+
ttlMs: options.presenceTtlMs,
|
|
164
|
+
})
|
|
165
|
+
const submitCommand = ({
|
|
166
|
+
actor,
|
|
167
|
+
command: input,
|
|
168
|
+
session,
|
|
169
|
+
}: {
|
|
170
|
+
readonly actor: string
|
|
171
|
+
readonly command: MarkdownCommand
|
|
172
|
+
readonly session: string
|
|
173
|
+
}): Promise<MosaicAcceptedDomainBatchEnvelope> =>
|
|
174
|
+
document.submit({
|
|
175
|
+
actor,
|
|
176
|
+
command:
|
|
177
|
+
input.type === `import`
|
|
178
|
+
? input
|
|
179
|
+
: {
|
|
180
|
+
gestureId: input.gestureId,
|
|
181
|
+
selection: { anchor: input.anchor, head: input.head },
|
|
182
|
+
sequence: input.sequence,
|
|
183
|
+
text: input.text,
|
|
184
|
+
type: `replace`,
|
|
185
|
+
},
|
|
186
|
+
session,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if (bootstrapText.length > 0) {
|
|
190
|
+
await submitCommand({
|
|
191
|
+
actor: `system`,
|
|
192
|
+
command: {
|
|
193
|
+
gestureId: `bootstrap`,
|
|
194
|
+
sequence: 1,
|
|
195
|
+
text: bootstrapText,
|
|
196
|
+
type: `import`,
|
|
197
|
+
},
|
|
198
|
+
session: `bootstrap`,
|
|
199
|
+
})
|
|
200
|
+
await history.flush()
|
|
201
|
+
}
|
|
202
|
+
bootstrapText = ``
|
|
203
|
+
|
|
204
|
+
let disposed = false
|
|
205
|
+
const cleanups = new Set<() => Promise<void>>()
|
|
206
|
+
const acknowledge = <Value>(
|
|
207
|
+
work: Promise<Value>,
|
|
208
|
+
respond: (acknowledgement: MarkdownAcknowledgement<Value>) => void,
|
|
209
|
+
): void => {
|
|
210
|
+
void work.then(
|
|
211
|
+
(value) => {
|
|
212
|
+
respond({ ok: true, value })
|
|
213
|
+
},
|
|
214
|
+
(error: unknown) => {
|
|
215
|
+
respond({
|
|
216
|
+
ok: false,
|
|
217
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
bindSocket({ actor, session, socket }) {
|
|
225
|
+
const unbindResidency = bindMosaicDomainResidencyServerSocket(
|
|
226
|
+
residency.connect({ actor, session }),
|
|
227
|
+
socket,
|
|
228
|
+
)
|
|
229
|
+
const unbindHistory = bindMosaicDomainHistoryServerSocket(
|
|
230
|
+
history.connect({ actor, session }),
|
|
231
|
+
socket,
|
|
232
|
+
{ session },
|
|
233
|
+
)
|
|
234
|
+
const unbindPresence = bindMosaicDomainPresenceServerSocket(
|
|
235
|
+
presence.connect({ actor, session }),
|
|
236
|
+
socket,
|
|
237
|
+
)
|
|
238
|
+
const onCommand = (input: MarkdownCommand, respond: any): void => {
|
|
239
|
+
acknowledge(submitCommand({ actor, command: input, session }), respond)
|
|
240
|
+
}
|
|
241
|
+
const onMaterialize = (respond: any): void => {
|
|
242
|
+
acknowledge(Promise.resolve(document.materialize()), respond)
|
|
243
|
+
}
|
|
244
|
+
const onPosition = (offset: number, respond: any): void => {
|
|
245
|
+
acknowledge(document.positionAtOffset(offset), respond)
|
|
246
|
+
}
|
|
247
|
+
const onResolve = (
|
|
248
|
+
position: MosaicTextRelativePosition,
|
|
249
|
+
respond: any,
|
|
250
|
+
): void => {
|
|
251
|
+
acknowledge(Promise.resolve(document.resolvePosition(position)), respond)
|
|
252
|
+
}
|
|
253
|
+
socket.on(MARKDOWN_EVENTS.command, onCommand)
|
|
254
|
+
socket.on(MARKDOWN_EVENTS.materialize, onMaterialize)
|
|
255
|
+
socket.on(MARKDOWN_EVENTS.positionAtOffset, onPosition)
|
|
256
|
+
socket.on(MARKDOWN_EVENTS.resolvePosition, onResolve)
|
|
257
|
+
let cleaned = false
|
|
258
|
+
const cleanup = async (): Promise<void> => {
|
|
259
|
+
if (cleaned) return
|
|
260
|
+
cleaned = true
|
|
261
|
+
socket.off(MARKDOWN_EVENTS.command, onCommand)
|
|
262
|
+
socket.off(MARKDOWN_EVENTS.materialize, onMaterialize)
|
|
263
|
+
socket.off(MARKDOWN_EVENTS.positionAtOffset, onPosition)
|
|
264
|
+
socket.off(MARKDOWN_EVENTS.resolvePosition, onResolve)
|
|
265
|
+
unbindResidency()
|
|
266
|
+
unbindHistory()
|
|
267
|
+
await unbindPresence()
|
|
268
|
+
cleanups.delete(cleanup)
|
|
269
|
+
}
|
|
270
|
+
cleanups.add(cleanup)
|
|
271
|
+
return Promise.resolve(cleanup)
|
|
272
|
+
},
|
|
273
|
+
command: submitCommand,
|
|
274
|
+
domain,
|
|
275
|
+
history: (identity) => history.connect(identity),
|
|
276
|
+
get instrumentation() {
|
|
277
|
+
return {
|
|
278
|
+
...document.instrumentation,
|
|
279
|
+
activeFullDocumentModels: 1 as const,
|
|
280
|
+
maximumFullDocumentModels: 1 as const,
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
get indexSummary() {
|
|
284
|
+
return document.indexSummary
|
|
285
|
+
},
|
|
286
|
+
inspectRange: document.inspectRange,
|
|
287
|
+
get length() {
|
|
288
|
+
return document.length
|
|
289
|
+
},
|
|
290
|
+
materialize: document.materialize,
|
|
291
|
+
positionAtOffset: document.positionAtOffset,
|
|
292
|
+
resolvePosition: (position) =>
|
|
293
|
+
Promise.resolve(document.resolvePosition(position)),
|
|
294
|
+
get revision() {
|
|
295
|
+
return batches.revision
|
|
296
|
+
},
|
|
297
|
+
sourceSnapshot: () => source().toJSON(),
|
|
298
|
+
[Symbol.dispose]() {
|
|
299
|
+
if (disposed) return
|
|
300
|
+
disposed = true
|
|
301
|
+
for (const cleanup of cleanups) void cleanup().catch(() => undefined)
|
|
302
|
+
presence[Symbol.dispose]()
|
|
303
|
+
residency[Symbol.dispose]()
|
|
304
|
+
history[Symbol.dispose]()
|
|
305
|
+
document[Symbol.dispose]()
|
|
306
|
+
batches.dispose()
|
|
307
|
+
domain[Symbol.dispose]()
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atom.io/template-react-realtime-text-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "A realtime text editor built with atom.io, React, and Lasertag.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,35 +10,43 @@
|
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"atom.io": "0.
|
|
14
|
-
"
|
|
15
|
-
"react
|
|
13
|
+
"@atom.io/react": "0.1.0",
|
|
14
|
+
"@atom.io/realtime": "0.1.0",
|
|
15
|
+
"@lexical/react": "0.52.0",
|
|
16
|
+
"@lexical/selection": "0.52.0",
|
|
17
|
+
"atom.io": "0.54.1",
|
|
18
|
+
"lexical": "0.52.0",
|
|
19
|
+
"react": "19.3.1",
|
|
20
|
+
"react-dom": "19.3.1",
|
|
16
21
|
"socket.io": "4.8.4",
|
|
17
22
|
"socket.io-client": "4.8.4",
|
|
18
|
-
"zod": "4.6.
|
|
23
|
+
"zod": "4.6.7"
|
|
19
24
|
},
|
|
20
25
|
"devDependencies": {
|
|
21
|
-
"@
|
|
22
|
-
"@dprint/
|
|
23
|
-
"@dprint/
|
|
26
|
+
"@atom.io/eslint-plugin": "0.1.0",
|
|
27
|
+
"@dprint/json": "0.25.2",
|
|
28
|
+
"@dprint/markdown": "0.28.2",
|
|
29
|
+
"@dprint/oxc": "0.47.0",
|
|
30
|
+
"@playwright/test": "1.64.1",
|
|
24
31
|
"@testing-library/dom": "10.4.2",
|
|
25
32
|
"@testing-library/react": "16.3.3",
|
|
26
|
-
"@types/node": "26.6.
|
|
33
|
+
"@types/node": "26.6.5",
|
|
27
34
|
"@types/react": "19.3.0",
|
|
28
35
|
"@types/react-dom": "19.3.0",
|
|
29
|
-
"@typescript-eslint/parser": "8.
|
|
30
|
-
"@
|
|
31
|
-
"
|
|
32
|
-
"
|
|
36
|
+
"@typescript-eslint/parser": "8.72.1",
|
|
37
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
38
|
+
"@vitejs/plugin-react": "6.1.2",
|
|
39
|
+
"concurrently": "10.0.6",
|
|
40
|
+
"dprint": "0.64.3",
|
|
33
41
|
"dprint-plugin-malva": "0.16.0",
|
|
34
|
-
"eslint": "10.
|
|
35
|
-
"happy-dom": "
|
|
36
|
-
"lasertag": "0.8.
|
|
37
|
-
"oxlint": "1.
|
|
38
|
-
"oxlint-tsgolint": "7.0.
|
|
39
|
-
"typescript": "
|
|
40
|
-
"vite": "8.3.
|
|
41
|
-
"vitest": "5.0.
|
|
42
|
+
"eslint": "10.13.0",
|
|
43
|
+
"happy-dom": "21.0.4",
|
|
44
|
+
"lasertag": "0.8.10",
|
|
45
|
+
"oxlint": "1.90.0",
|
|
46
|
+
"oxlint-tsgolint": "7.0.2005",
|
|
47
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
48
|
+
"vite": "8.3.5",
|
|
49
|
+
"vitest": "5.0.5"
|
|
42
50
|
},
|
|
43
51
|
"engines": {
|
|
44
52
|
"node": ">=24.12.0"
|
|
@@ -58,6 +66,7 @@
|
|
|
58
66
|
"lint:types": "tsc -b",
|
|
59
67
|
"preview": "concurrently \"vite preview\" \"node node/server.ts\"",
|
|
60
68
|
"test": "vitest run",
|
|
69
|
+
"test:browser": "playwright test",
|
|
61
70
|
"test:once": "vitest run"
|
|
62
71
|
}
|
|
63
72
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { defineConfig } from "@playwright/test"
|
|
2
|
+
|
|
3
|
+
const FRONTEND_PORT = 5_527
|
|
4
|
+
const executablePath = process.env.MOSAIC_PLAYWRIGHT_EXECUTABLE_PATH
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
fullyParallel: false,
|
|
8
|
+
outputDir: `test-results/browser`,
|
|
9
|
+
reporter: process.env.CI ? [[`github`], [`list`]] : `list`,
|
|
10
|
+
retries: 0,
|
|
11
|
+
testDir: `tests/browser`,
|
|
12
|
+
timeout: 60_000,
|
|
13
|
+
use: {
|
|
14
|
+
baseURL: `http://127.0.0.1:${FRONTEND_PORT}`,
|
|
15
|
+
launchOptions:
|
|
16
|
+
executablePath === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: {
|
|
19
|
+
executablePath,
|
|
20
|
+
},
|
|
21
|
+
screenshot: `only-on-failure`,
|
|
22
|
+
trace: `retain-on-failure`,
|
|
23
|
+
video: `retain-on-failure`,
|
|
24
|
+
},
|
|
25
|
+
webServer: [
|
|
26
|
+
{
|
|
27
|
+
command: `pnpm exec vite --mode browser-conformance --host 127.0.0.1 --port ${FRONTEND_PORT} --strictPort`,
|
|
28
|
+
reuseExistingServer: false,
|
|
29
|
+
timeout: 30_000,
|
|
30
|
+
url: `http://127.0.0.1:${FRONTEND_PORT}`,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
workers: 1,
|
|
34
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.LexicalMarkdownEditor_editor {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: block;
|
|
4
|
+
min-height: 32rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.LexicalMarkdownEditor_contentEditable {
|
|
8
|
+
position: relative;
|
|
9
|
+
z-index: 1;
|
|
10
|
+
min-height: 32rem;
|
|
11
|
+
padding: 1.45rem 1.45rem 3rem 3.7rem;
|
|
12
|
+
outline: 0;
|
|
13
|
+
color: var(--editor-ink);
|
|
14
|
+
caret-color: var(--accent);
|
|
15
|
+
font-family: "DM Mono", monospace;
|
|
16
|
+
font-size: 0.83rem;
|
|
17
|
+
line-height: 1.72rem;
|
|
18
|
+
overflow-wrap: anywhere;
|
|
19
|
+
tab-size: 2;
|
|
20
|
+
white-space: pre-wrap;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.LexicalMarkdownEditor_paragraph {
|
|
24
|
+
margin: 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.LexicalMarkdownEditor_placeholder {
|
|
28
|
+
position: absolute;
|
|
29
|
+
top: 1.45rem;
|
|
30
|
+
left: 3.7rem;
|
|
31
|
+
display: block;
|
|
32
|
+
color: var(--editor-placeholder);
|
|
33
|
+
font-family: "DM Mono", monospace;
|
|
34
|
+
font-size: 0.83rem;
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.LexicalMarkdownEditor_overlays {
|
|
39
|
+
position: absolute;
|
|
40
|
+
z-index: 2;
|
|
41
|
+
inset: 0;
|
|
42
|
+
display: block;
|
|
43
|
+
pointer-events: none;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.LexicalMarkdownEditor_presence {
|
|
47
|
+
display: contents;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.LexicalMarkdownEditor_selection,
|
|
51
|
+
.LexicalMarkdownEditor_caret {
|
|
52
|
+
position: absolute;
|
|
53
|
+
display: block;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.LexicalMarkdownEditor_selection {
|
|
57
|
+
border-radius: 0.18rem;
|
|
58
|
+
background: color-mix(
|
|
59
|
+
in srgb,
|
|
60
|
+
var(--collaborator-color) 25%,
|
|
61
|
+
transparent
|
|
62
|
+
);
|
|
63
|
+
box-shadow: inset 0 -1px var(--collaborator-color);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.LexicalMarkdownEditor_caret {
|
|
67
|
+
width: 2px;
|
|
68
|
+
background: var(--collaborator-color);
|
|
69
|
+
box-shadow: 0 0 0 1px var(--overlay-outline);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.LexicalMarkdownEditor_label {
|
|
73
|
+
position: absolute;
|
|
74
|
+
top: -1.25rem;
|
|
75
|
+
left: -1px;
|
|
76
|
+
display: block;
|
|
77
|
+
max-width: 10rem;
|
|
78
|
+
padding: 0.16rem 0.38rem;
|
|
79
|
+
border-radius: 0.28rem 0.28rem 0.28rem 0;
|
|
80
|
+
color: white;
|
|
81
|
+
background: var(--collaborator-color);
|
|
82
|
+
font-family: system-ui, sans-serif;
|
|
83
|
+
font-size: 0.58rem;
|
|
84
|
+
font-weight: 750;
|
|
85
|
+
line-height: 1.05rem;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
white-space: nowrap;
|
|
88
|
+
}
|