@atom.io/template-react-realtime-text-editor 0.0.10 → 0.0.12
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 +23 -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 +32 -23
- 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,806 @@
|
|
|
1
|
+
import {
|
|
2
|
+
expect,
|
|
3
|
+
test,
|
|
4
|
+
type Browser,
|
|
5
|
+
type BrowserContext,
|
|
6
|
+
type Page,
|
|
7
|
+
} from "@playwright/test"
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createMarkdownCollaborationHttpServer,
|
|
11
|
+
type MarkdownCollaborationHttpServer,
|
|
12
|
+
} from "../../node/http-server.ts"
|
|
13
|
+
|
|
14
|
+
const EDITOR = `[data-lexical-editor="true"]`
|
|
15
|
+
const WRITER_MARKER = `[WRITER-PREFIX]`
|
|
16
|
+
const BACKEND_PORT = 3_027
|
|
17
|
+
|
|
18
|
+
let backend: MarkdownCollaborationHttpServer
|
|
19
|
+
|
|
20
|
+
type Clients = {
|
|
21
|
+
readonly maya: Page
|
|
22
|
+
readonly mayaContext: BrowserContext
|
|
23
|
+
readonly theo: Page
|
|
24
|
+
readonly theoContext: BrowserContext
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type CursorSample = {
|
|
28
|
+
readonly end: number | null
|
|
29
|
+
readonly kind: number
|
|
30
|
+
readonly length: number
|
|
31
|
+
readonly phase: number
|
|
32
|
+
readonly rect: {
|
|
33
|
+
readonly height: number
|
|
34
|
+
readonly left: number
|
|
35
|
+
readonly top: number
|
|
36
|
+
} | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ColorSchemeSnapshot = {
|
|
40
|
+
readonly editorBackground: string
|
|
41
|
+
readonly editorInk: string
|
|
42
|
+
readonly footerBackground: string
|
|
43
|
+
readonly pageBackground: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type CursorSamplerWindow = Window & {
|
|
47
|
+
__mosaicCursorFrame?: number
|
|
48
|
+
__mosaicCursorSamples?: CursorSample[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type VisibleCursorSample = {
|
|
52
|
+
readonly caret: {
|
|
53
|
+
readonly height: number
|
|
54
|
+
readonly left: number
|
|
55
|
+
readonly top: number
|
|
56
|
+
} | null
|
|
57
|
+
readonly expected: {
|
|
58
|
+
readonly height: number
|
|
59
|
+
readonly left: number
|
|
60
|
+
readonly top: number
|
|
61
|
+
} | null
|
|
62
|
+
readonly phase: number
|
|
63
|
+
readonly reason: `animation-frame` | `mutation`
|
|
64
|
+
readonly overlayCount: number
|
|
65
|
+
readonly selectionEnd: number | null
|
|
66
|
+
readonly session: string | null
|
|
67
|
+
readonly targetIndex: number
|
|
68
|
+
readonly textLength: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type VisibleCursorSamplerWindow = Window & {
|
|
72
|
+
__mosaicVisibleCursorFrame?: number
|
|
73
|
+
__mosaicVisibleCursorObserver?: MutationObserver
|
|
74
|
+
__mosaicVisibleCursorPhase?: number
|
|
75
|
+
__mosaicVisibleCursorSamples?: VisibleCursorSample[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type WriterOrderObservation = {
|
|
79
|
+
readonly activeElement: string | null
|
|
80
|
+
readonly actual: string
|
|
81
|
+
readonly anchorOffset: number | null
|
|
82
|
+
readonly anchorText: string | null
|
|
83
|
+
readonly character: string
|
|
84
|
+
readonly collapsed: boolean | null
|
|
85
|
+
readonly expected: string
|
|
86
|
+
readonly index: number
|
|
87
|
+
readonly phase: `immediate` | `paced`
|
|
88
|
+
readonly status: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function openClients(browser: Browser): Promise<Clients> {
|
|
92
|
+
const mayaContext = await browser.newContext()
|
|
93
|
+
const theoContext = await browser.newContext()
|
|
94
|
+
const maya = await mayaContext.newPage()
|
|
95
|
+
const theo = await theoContext.newPage()
|
|
96
|
+
await Promise.all([maya.goto(`/?as=maya`), theo.goto(`/?as=theo`)])
|
|
97
|
+
await Promise.all([
|
|
98
|
+
maya.locator(EDITOR).waitFor({ state: `visible` }),
|
|
99
|
+
theo.locator(EDITOR).waitFor({ state: `visible` }),
|
|
100
|
+
])
|
|
101
|
+
await Promise.all([waitForSaved(maya), waitForSaved(theo)])
|
|
102
|
+
return { maya, mayaContext, theo, theoContext }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function closeClients(clients: Clients): Promise<void> {
|
|
106
|
+
await Promise.all([clients.mayaContext.close(), clients.theoContext.close()])
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function sourceText(page: Page): Promise<string> {
|
|
110
|
+
const text = await page.locator(EDITOR).innerText()
|
|
111
|
+
// Chromium exposes Lexical's managed empty-paragraph <br> as one visual
|
|
112
|
+
// newline even though the editor model contains zero UTF-16 units.
|
|
113
|
+
return text === `\n` ? `` : text
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function colorSchemeSnapshot(page: Page): Promise<ColorSchemeSnapshot> {
|
|
117
|
+
return page.evaluate(() => {
|
|
118
|
+
const editorPane = document.querySelector(`editor-pane`)
|
|
119
|
+
const editor = document.querySelector(`[data-lexical-editor="true"]`)
|
|
120
|
+
const footer = document.querySelector(`footer`)
|
|
121
|
+
if (editorPane === null || editor === null || footer === null) {
|
|
122
|
+
throw new Error(`The themed editor surface is not ready.`)
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
editorBackground: getComputedStyle(editorPane).backgroundColor,
|
|
126
|
+
editorInk: getComputedStyle(editor).color,
|
|
127
|
+
footerBackground: getComputedStyle(footer).backgroundColor,
|
|
128
|
+
pageBackground: getComputedStyle(document.documentElement).backgroundColor,
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function waitForSaved(page: Page): Promise<void> {
|
|
134
|
+
await expect(page.locator(`footer strong`)).toHaveText(`All changes saved`, {
|
|
135
|
+
timeout: 10_000,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function waitForText(page: Page, expected: string): Promise<void> {
|
|
140
|
+
await expect.poll(() => sourceText(page), { timeout: 10_000 }).toBe(expected)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function moveCaret(page: Page, offset: number): Promise<void> {
|
|
144
|
+
if (!Number.isSafeInteger(offset) || offset < 0) {
|
|
145
|
+
throw new Error(`Caret offset must be a non-negative integer.`)
|
|
146
|
+
}
|
|
147
|
+
await page.locator(EDITOR).click()
|
|
148
|
+
await page.keyboard.press(`ControlOrMeta+Home`)
|
|
149
|
+
for (let index = 0; index < offset; index++) {
|
|
150
|
+
await page.keyboard.press(`ArrowRight`)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function observeWriterOrder(
|
|
155
|
+
page: Page,
|
|
156
|
+
input: Omit<
|
|
157
|
+
WriterOrderObservation,
|
|
158
|
+
| `activeElement`
|
|
159
|
+
| `actual`
|
|
160
|
+
| `anchorOffset`
|
|
161
|
+
| `anchorText`
|
|
162
|
+
| `collapsed`
|
|
163
|
+
| `status`
|
|
164
|
+
>,
|
|
165
|
+
): Promise<WriterOrderObservation> {
|
|
166
|
+
const browser = await page.evaluate((editorSelector) => {
|
|
167
|
+
const root = document.querySelector<HTMLElement>(editorSelector)
|
|
168
|
+
const selection = window.getSelection()
|
|
169
|
+
const anchor = selection?.anchorNode
|
|
170
|
+
return {
|
|
171
|
+
activeElement:
|
|
172
|
+
document.activeElement instanceof HTMLElement
|
|
173
|
+
? (document.activeElement.getAttribute(`data-lexical-editor`) ??
|
|
174
|
+
document.activeElement.tagName)
|
|
175
|
+
: null,
|
|
176
|
+
actual: root?.innerText === `\n` ? `` : (root?.innerText ?? ``),
|
|
177
|
+
anchorOffset: selection?.anchorOffset ?? null,
|
|
178
|
+
anchorText: anchor instanceof Text ? anchor.data : null,
|
|
179
|
+
collapsed: selection?.isCollapsed ?? null,
|
|
180
|
+
status: document.querySelector(`footer strong`)?.textContent ?? ``,
|
|
181
|
+
}
|
|
182
|
+
}, EDITOR)
|
|
183
|
+
return { ...input, ...browser }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Cursor geometry tests need a semantic starting point, not hundreds of
|
|
187
|
+
// ArrowRight round trips. The actual edits and subsequent arrow keys stay native.
|
|
188
|
+
async function moveCaretToText(page: Page, text: string): Promise<void> {
|
|
189
|
+
await page.locator(EDITOR).evaluate((root, target) => {
|
|
190
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
191
|
+
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
|
|
192
|
+
const offset = node.textContent?.indexOf(target) ?? -1
|
|
193
|
+
if (offset < 0) continue
|
|
194
|
+
;(root as HTMLElement).focus()
|
|
195
|
+
window.getSelection()!.setBaseAndExtent(node, offset, node, offset)
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`Caret target not found: ${target}`)
|
|
199
|
+
}, text)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function selectGraphemeRange(
|
|
203
|
+
page: Page,
|
|
204
|
+
startIndex: number,
|
|
205
|
+
endIndex: number,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
await moveCaret(page, startIndex)
|
|
208
|
+
for (let index = startIndex; index < endIndex; index++) {
|
|
209
|
+
await page.keyboard.press(`Shift+ArrowRight`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function focusLineStart(page: Page, prefix: string): Promise<void> {
|
|
214
|
+
const line = page
|
|
215
|
+
.locator(`${EDITOR} [data-lexical-text="true"]`)
|
|
216
|
+
.filter({ hasText: new RegExp(`^${prefix.replaceAll(`.`, `\\.`)}`) })
|
|
217
|
+
.first()
|
|
218
|
+
await expect(line).toBeVisible()
|
|
219
|
+
// Click the first rendered row, not the locator's visual center: long logical
|
|
220
|
+
// lines can wrap, and Home operates on the clicked visual row.
|
|
221
|
+
await line.click({ position: { x: 2, y: 2 } })
|
|
222
|
+
await page.keyboard.press(`Home`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function typeLiteral(
|
|
226
|
+
page: Page,
|
|
227
|
+
value: string,
|
|
228
|
+
delay = 35,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
for (const character of value) {
|
|
231
|
+
if (character === `\n`) await page.keyboard.press(`Enter`)
|
|
232
|
+
else await page.keyboard.type(character)
|
|
233
|
+
if (delay > 0) await page.waitForTimeout(delay)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function startCursorSampler(
|
|
238
|
+
page: Page,
|
|
239
|
+
session: string,
|
|
240
|
+
semanticTarget: string,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
await page.evaluate(
|
|
243
|
+
({ exactSession, targetText }) => {
|
|
244
|
+
const target = window as CursorSamplerWindow
|
|
245
|
+
target.__mosaicCursorSamples = []
|
|
246
|
+
let phase = 0
|
|
247
|
+
const sample = (): void => {
|
|
248
|
+
const root = document.querySelector<HTMLElement>(
|
|
249
|
+
`[data-lexical-editor="true"]`,
|
|
250
|
+
)
|
|
251
|
+
const text = root?.innerText ?? ``
|
|
252
|
+
const overlay = document.querySelector<HTMLElement>(
|
|
253
|
+
`collaborator-presence[data-session="${exactSession}"]`,
|
|
254
|
+
)
|
|
255
|
+
const caret = overlay?.querySelector<HTMLElement>(`collaborator-caret`)
|
|
256
|
+
const rect = caret?.getBoundingClientRect()
|
|
257
|
+
target.__mosaicCursorSamples!.push({
|
|
258
|
+
end:
|
|
259
|
+
overlay === null
|
|
260
|
+
? null
|
|
261
|
+
: Number(overlay.getAttribute(`data-selection-end`)),
|
|
262
|
+
kind: text.lastIndexOf(targetText),
|
|
263
|
+
length: text.length,
|
|
264
|
+
phase: phase++,
|
|
265
|
+
rect:
|
|
266
|
+
rect === undefined
|
|
267
|
+
? null
|
|
268
|
+
: { height: rect.height, left: rect.left, top: rect.top },
|
|
269
|
+
})
|
|
270
|
+
target.__mosaicCursorFrame = requestAnimationFrame(sample)
|
|
271
|
+
}
|
|
272
|
+
target.__mosaicCursorFrame = requestAnimationFrame(sample)
|
|
273
|
+
},
|
|
274
|
+
{ exactSession: session, targetText: semanticTarget },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function stopCursorSampler(page: Page): Promise<readonly CursorSample[]> {
|
|
279
|
+
return page.evaluate(() => {
|
|
280
|
+
const target = window as CursorSamplerWindow
|
|
281
|
+
if (target.__mosaicCursorFrame !== undefined) {
|
|
282
|
+
cancelAnimationFrame(target.__mosaicCursorFrame)
|
|
283
|
+
}
|
|
284
|
+
return target.__mosaicCursorSamples ?? []
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function startVisibleCursorSampler(
|
|
289
|
+
page: Page,
|
|
290
|
+
collaborator: string,
|
|
291
|
+
semanticTarget: string,
|
|
292
|
+
): Promise<void> {
|
|
293
|
+
await page.evaluate(
|
|
294
|
+
({ collaboratorName, targetText }) => {
|
|
295
|
+
const target = window as VisibleCursorSamplerWindow
|
|
296
|
+
target.__mosaicVisibleCursorSamples = []
|
|
297
|
+
target.__mosaicVisibleCursorPhase = 0
|
|
298
|
+
|
|
299
|
+
const expectedCaret = (): DOMRect | null => {
|
|
300
|
+
const root = document.querySelector<HTMLElement>(
|
|
301
|
+
`[data-lexical-editor="true"]`,
|
|
302
|
+
)
|
|
303
|
+
if (root === null) return null
|
|
304
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
|
305
|
+
let match: { readonly index: number; readonly node: Text } | null = null
|
|
306
|
+
for (
|
|
307
|
+
let node = walker.nextNode();
|
|
308
|
+
node !== null;
|
|
309
|
+
node = walker.nextNode()
|
|
310
|
+
) {
|
|
311
|
+
if (!(node instanceof Text)) continue
|
|
312
|
+
const index = node.data.lastIndexOf(targetText)
|
|
313
|
+
if (index >= 0) match = { index, node }
|
|
314
|
+
}
|
|
315
|
+
if (match === null) return null
|
|
316
|
+
const range = document.createRange()
|
|
317
|
+
range.setStart(match.node, match.index)
|
|
318
|
+
range.setEnd(match.node, match.index + 1)
|
|
319
|
+
return range.getBoundingClientRect()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const capture = (reason: `animation-frame` | `mutation`): void => {
|
|
323
|
+
const root = document.querySelector<HTMLElement>(
|
|
324
|
+
`[data-lexical-editor="true"]`,
|
|
325
|
+
)
|
|
326
|
+
const expected = expectedCaret()
|
|
327
|
+
const overlays = document.querySelectorAll<HTMLElement>(
|
|
328
|
+
`collaborator-presence[data-collaborator="${collaboratorName}"]`,
|
|
329
|
+
)
|
|
330
|
+
const matchedOverlays: readonly (HTMLElement | null)[] =
|
|
331
|
+
overlays.length === 0 ? [null] : [...overlays]
|
|
332
|
+
for (const overlay of matchedOverlays) {
|
|
333
|
+
const caret = overlay?.querySelector<HTMLElement>(`collaborator-caret`)
|
|
334
|
+
const caretRect = caret?.getBoundingClientRect()
|
|
335
|
+
const selectionEnd = overlay?.getAttribute(`data-selection-end`)
|
|
336
|
+
target.__mosaicVisibleCursorSamples!.push({
|
|
337
|
+
caret:
|
|
338
|
+
caretRect === undefined
|
|
339
|
+
? null
|
|
340
|
+
: {
|
|
341
|
+
height: caretRect.height,
|
|
342
|
+
left: caretRect.left,
|
|
343
|
+
top: caretRect.top,
|
|
344
|
+
},
|
|
345
|
+
expected:
|
|
346
|
+
expected === null
|
|
347
|
+
? null
|
|
348
|
+
: {
|
|
349
|
+
height: expected.height,
|
|
350
|
+
left: expected.left,
|
|
351
|
+
top: expected.top,
|
|
352
|
+
},
|
|
353
|
+
phase: target.__mosaicVisibleCursorPhase ?? 0,
|
|
354
|
+
reason,
|
|
355
|
+
overlayCount: overlays.length,
|
|
356
|
+
selectionEnd:
|
|
357
|
+
selectionEnd !== null && Number.isFinite(Number(selectionEnd))
|
|
358
|
+
? Number(selectionEnd)
|
|
359
|
+
: null,
|
|
360
|
+
session: overlay?.getAttribute(`data-session`) ?? null,
|
|
361
|
+
targetIndex: root?.innerText.lastIndexOf(targetText) ?? -1,
|
|
362
|
+
textLength: root?.innerText.length ?? 0,
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
target.__mosaicVisibleCursorPhase =
|
|
366
|
+
(target.__mosaicVisibleCursorPhase ?? 0) + 1
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
target.__mosaicVisibleCursorObserver = new MutationObserver(() => {
|
|
370
|
+
capture(`mutation`)
|
|
371
|
+
})
|
|
372
|
+
const overlayRoot = document.querySelector(`collaborator-overlays`)
|
|
373
|
+
if (overlayRoot !== null) {
|
|
374
|
+
target.__mosaicVisibleCursorObserver.observe(overlayRoot, {
|
|
375
|
+
attributeFilter: [
|
|
376
|
+
`data-selection-end`,
|
|
377
|
+
`data-selection-start`,
|
|
378
|
+
`style`,
|
|
379
|
+
],
|
|
380
|
+
attributes: true,
|
|
381
|
+
childList: true,
|
|
382
|
+
subtree: true,
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
const frame = (): void => {
|
|
386
|
+
capture(`animation-frame`)
|
|
387
|
+
target.__mosaicVisibleCursorFrame = requestAnimationFrame(frame)
|
|
388
|
+
}
|
|
389
|
+
capture(`mutation`)
|
|
390
|
+
target.__mosaicVisibleCursorFrame = requestAnimationFrame(frame)
|
|
391
|
+
},
|
|
392
|
+
{ collaboratorName: collaborator, targetText: semanticTarget },
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function stopVisibleCursorSampler(
|
|
397
|
+
page: Page,
|
|
398
|
+
): Promise<readonly VisibleCursorSample[]> {
|
|
399
|
+
return page.evaluate(() => {
|
|
400
|
+
const target = window as VisibleCursorSamplerWindow
|
|
401
|
+
if (target.__mosaicVisibleCursorFrame !== undefined) {
|
|
402
|
+
cancelAnimationFrame(target.__mosaicVisibleCursorFrame)
|
|
403
|
+
}
|
|
404
|
+
target.__mosaicVisibleCursorObserver?.disconnect()
|
|
405
|
+
return target.__mosaicVisibleCursorSamples ?? []
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
test.describe(`Mosaic text editor browser conformance`, () => {
|
|
410
|
+
test.beforeEach(async () => {
|
|
411
|
+
backend = await createMarkdownCollaborationHttpServer({ port: BACKEND_PORT })
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
test.afterEach(async () => {
|
|
415
|
+
await backend.close()
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
test(`starts dark and follows the system light color scheme`, async ({
|
|
419
|
+
page,
|
|
420
|
+
}) => {
|
|
421
|
+
await page.emulateMedia({ colorScheme: `dark` })
|
|
422
|
+
await page.goto(`/?as=maya`)
|
|
423
|
+
await expect(page.locator(`meta[name="color-scheme"]`)).toHaveAttribute(
|
|
424
|
+
`content`,
|
|
425
|
+
`dark light`,
|
|
426
|
+
)
|
|
427
|
+
await page.locator(EDITOR).waitFor({ state: `visible` })
|
|
428
|
+
await waitForSaved(page)
|
|
429
|
+
expect(await colorSchemeSnapshot(page)).toEqual({
|
|
430
|
+
editorBackground: `rgb(28, 25, 34)`,
|
|
431
|
+
editorInk: `rgb(229, 223, 234)`,
|
|
432
|
+
footerBackground: `rgb(25, 23, 31)`,
|
|
433
|
+
pageBackground: `rgb(18, 16, 22)`,
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
await page.emulateMedia({ colorScheme: `light` })
|
|
437
|
+
await expect
|
|
438
|
+
.poll(() => colorSchemeSnapshot(page))
|
|
439
|
+
.toEqual({
|
|
440
|
+
editorBackground: `rgb(255, 254, 250)`,
|
|
441
|
+
editorInk: `rgb(56, 53, 66)`,
|
|
442
|
+
footerBackground: `rgb(250, 249, 252)`,
|
|
443
|
+
pageBackground: `rgb(243, 242, 247)`,
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test(`delete-all and immediate retyping preserve exact intent`, async ({
|
|
448
|
+
browser,
|
|
449
|
+
}) => {
|
|
450
|
+
const clients = await openClients(browser)
|
|
451
|
+
try {
|
|
452
|
+
expect((await sourceText(clients.maya)).length).toBeGreaterThan(0)
|
|
453
|
+
const editor = clients.maya.locator(EDITOR)
|
|
454
|
+
await editor.click()
|
|
455
|
+
await clients.maya.keyboard.press(`ControlOrMeta+A`)
|
|
456
|
+
await clients.maya.keyboard.press(`Backspace`)
|
|
457
|
+
expect(await sourceText(clients.maya)).toBe(``)
|
|
458
|
+
|
|
459
|
+
const replacement = `[RESET-ALL] fresh text`
|
|
460
|
+
let expected = ``
|
|
461
|
+
for (const character of replacement) {
|
|
462
|
+
await clients.maya.keyboard.type(character)
|
|
463
|
+
expected += character
|
|
464
|
+
expect(await sourceText(clients.maya)).toBe(expected)
|
|
465
|
+
await clients.maya.waitForTimeout(45)
|
|
466
|
+
}
|
|
467
|
+
await waitForSaved(clients.maya)
|
|
468
|
+
await waitForText(clients.theo, replacement)
|
|
469
|
+
await clients.theo.reload()
|
|
470
|
+
await clients.theo.locator(EDITOR).waitFor({ state: `visible` })
|
|
471
|
+
await waitForText(clients.theo, replacement)
|
|
472
|
+
} finally {
|
|
473
|
+
await closeClients(clients)
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
test(`typing from a settled empty-row boundary stays on that row`, async ({
|
|
478
|
+
browser,
|
|
479
|
+
}) => {
|
|
480
|
+
const clients = await openClients(browser)
|
|
481
|
+
try {
|
|
482
|
+
await focusLineStart(clients.maya, `1.`)
|
|
483
|
+
await clients.maya.keyboard.press(`Enter`)
|
|
484
|
+
await clients.maya.keyboard.press(`Enter`)
|
|
485
|
+
await clients.maya.keyboard.press(`Enter`)
|
|
486
|
+
await waitForSaved(clients.maya)
|
|
487
|
+
|
|
488
|
+
const afterBlanks = await sourceText(clients.maya)
|
|
489
|
+
const list = afterBlanks.indexOf(`1.`)
|
|
490
|
+
expect(list).toBeGreaterThan(0)
|
|
491
|
+
await focusLineStart(clients.maya, `1.`)
|
|
492
|
+
await clients.maya.keyboard.press(`Enter`)
|
|
493
|
+
await waitForSaved(clients.maya)
|
|
494
|
+
await clients.maya.keyboard.press(`ArrowUp`)
|
|
495
|
+
await typeLiteral(clients.maya, `[EMPTY-LINE]`, 70)
|
|
496
|
+
await waitForSaved(clients.maya)
|
|
497
|
+
|
|
498
|
+
const settled = await sourceText(clients.maya)
|
|
499
|
+
expect(settled).toContain(`\n[EMPTY-LINE]\n1.`)
|
|
500
|
+
expect(settled).not.toContain(`[EMPTY-LINE]1.`)
|
|
501
|
+
await waitForText(clients.theo, settled)
|
|
502
|
+
await clients.theo.reload()
|
|
503
|
+
await clients.theo.locator(EDITOR).waitFor({ state: `visible` })
|
|
504
|
+
await waitForText(clients.theo, settled)
|
|
505
|
+
} finally {
|
|
506
|
+
await closeClients(clients)
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
test(`native grapheme selection and replacement preserve exact text`, async ({
|
|
511
|
+
browser,
|
|
512
|
+
}) => {
|
|
513
|
+
const clients = await openClients(browser)
|
|
514
|
+
try {
|
|
515
|
+
await clients.maya.locator(EDITOR).click()
|
|
516
|
+
await clients.maya.keyboard.press(`ControlOrMeta+A`)
|
|
517
|
+
await clients.maya.keyboard.press(`Backspace`)
|
|
518
|
+
await waitForText(clients.maya, ``)
|
|
519
|
+
await waitForSaved(clients.maya)
|
|
520
|
+
await clients.maya.keyboard.insertText(`a😀bc`)
|
|
521
|
+
await waitForText(clients.maya, `a😀bc`)
|
|
522
|
+
await waitForSaved(clients.maya)
|
|
523
|
+
|
|
524
|
+
// Keep native keyboard selection across a surrogate pair in the browser;
|
|
525
|
+
// the larger seeded oracle lives in collaboration.test.tsx.
|
|
526
|
+
const replacements = [
|
|
527
|
+
{ start: 1, end: 2, text: `x`, expected: `axbc` },
|
|
528
|
+
{ start: 2, end: 2, text: `😀`, expected: `ax😀bc` },
|
|
529
|
+
{ start: 1, end: 4, text: `!`, expected: `a!c` },
|
|
530
|
+
] as const
|
|
531
|
+
for (const replacement of replacements) {
|
|
532
|
+
await selectGraphemeRange(
|
|
533
|
+
clients.maya,
|
|
534
|
+
replacement.start,
|
|
535
|
+
replacement.end,
|
|
536
|
+
)
|
|
537
|
+
await clients.maya.keyboard.insertText(replacement.text)
|
|
538
|
+
expect(await sourceText(clients.maya)).toBe(replacement.expected)
|
|
539
|
+
}
|
|
540
|
+
await waitForSaved(clients.maya)
|
|
541
|
+
await waitForText(clients.theo, `a!c`)
|
|
542
|
+
} finally {
|
|
543
|
+
await closeClients(clients)
|
|
544
|
+
}
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test(`human-paced typing on a new line preserves keystroke order`, async ({
|
|
548
|
+
browser,
|
|
549
|
+
}) => {
|
|
550
|
+
test.setTimeout(60_000)
|
|
551
|
+
const clients = await openClients(browser)
|
|
552
|
+
try {
|
|
553
|
+
const editor = clients.maya.locator(EDITOR)
|
|
554
|
+
await editor.click()
|
|
555
|
+
await clients.maya.keyboard.press(`ControlOrMeta+A`)
|
|
556
|
+
await clients.maya.keyboard.press(`Backspace`)
|
|
557
|
+
await waitForText(clients.maya, ``)
|
|
558
|
+
await waitForSaved(clients.maya)
|
|
559
|
+
const fixture = `anchor\n\nsentinel`
|
|
560
|
+
await clients.maya.keyboard.insertText(fixture)
|
|
561
|
+
await waitForText(clients.maya, fixture)
|
|
562
|
+
await waitForSaved(clients.maya)
|
|
563
|
+
await waitForText(clients.theo, fixture)
|
|
564
|
+
const insertion = fixture.indexOf(`\n`) + 1
|
|
565
|
+
await moveCaret(clients.maya, insertion)
|
|
566
|
+
const phrase = `The quick brown fox jumps over the lazy dog`
|
|
567
|
+
const observations: WriterOrderObservation[] = []
|
|
568
|
+
let typed = ``
|
|
569
|
+
|
|
570
|
+
for (const [index, character] of [...phrase].entries()) {
|
|
571
|
+
await clients.maya.keyboard.type(character)
|
|
572
|
+
typed += character
|
|
573
|
+
const expected = `${fixture.slice(0, insertion)}${typed}${fixture.slice(insertion)}`
|
|
574
|
+
observations.push(
|
|
575
|
+
await observeWriterOrder(clients.maya, {
|
|
576
|
+
character,
|
|
577
|
+
expected,
|
|
578
|
+
index,
|
|
579
|
+
phase: `immediate`,
|
|
580
|
+
}),
|
|
581
|
+
)
|
|
582
|
+
await clients.maya.waitForTimeout(200)
|
|
583
|
+
observations.push(
|
|
584
|
+
await observeWriterOrder(clients.maya, {
|
|
585
|
+
character,
|
|
586
|
+
expected,
|
|
587
|
+
index,
|
|
588
|
+
phase: `paced`,
|
|
589
|
+
}),
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
const firstFailure = observations.find(
|
|
593
|
+
(observation) => observation.actual !== observation.expected,
|
|
594
|
+
)
|
|
595
|
+
expect(
|
|
596
|
+
firstFailure,
|
|
597
|
+
`A human-paced key was committed out of order. First failure:\n${JSON.stringify(firstFailure, null, 2)}\nRecent writer trace:\n${JSON.stringify(observations.slice(-8), null, 2)}`,
|
|
598
|
+
).toBeUndefined()
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const expected = `${fixture.slice(0, insertion)}${phrase}${fixture.slice(insertion)}`
|
|
602
|
+
await waitForSaved(clients.maya)
|
|
603
|
+
await waitForText(clients.theo, expected)
|
|
604
|
+
expect(await sourceText(clients.maya)).toBe(expected)
|
|
605
|
+
} finally {
|
|
606
|
+
await closeClients(clients)
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test(`remote cursors follow their semantic target in every frame`, async ({
|
|
611
|
+
browser,
|
|
612
|
+
}) => {
|
|
613
|
+
const clients = await openClients(browser)
|
|
614
|
+
try {
|
|
615
|
+
const fixture = await sourceText(clients.maya)
|
|
616
|
+
const kind = fixture.lastIndexOf(`kind`)
|
|
617
|
+
await moveCaretToText(clients.theo, `kind`)
|
|
618
|
+
await clients.theo.keyboard.press(`ArrowRight`)
|
|
619
|
+
await clients.theo.keyboard.press(`ArrowLeft`)
|
|
620
|
+
const presence = clients.maya.locator(
|
|
621
|
+
`collaborator-presence[data-collaborator="Theo Brooks"]`,
|
|
622
|
+
)
|
|
623
|
+
await expect(presence).toHaveCount(1)
|
|
624
|
+
await expect(presence).toHaveAttribute(`data-selection-end`, String(kind))
|
|
625
|
+
const session = await presence.getAttribute(`data-session`)
|
|
626
|
+
expect(session).not.toBeNull()
|
|
627
|
+
if (session === null) throw new Error(`Theo's presence has no session.`)
|
|
628
|
+
|
|
629
|
+
await startCursorSampler(clients.maya, session, `kind`)
|
|
630
|
+
|
|
631
|
+
await moveCaret(clients.maya, 0)
|
|
632
|
+
await typeLiteral(clients.maya, WRITER_MARKER, 80)
|
|
633
|
+
await clients.maya.waitForTimeout(500)
|
|
634
|
+
const samples = await stopCursorSampler(clients.maya)
|
|
635
|
+
expect(samples.length).toBeGreaterThan(WRITER_MARKER.length)
|
|
636
|
+
const invalid = samples.filter(
|
|
637
|
+
(sample) =>
|
|
638
|
+
sample.end === null ||
|
|
639
|
+
sample.rect === null ||
|
|
640
|
+
sample.rect.height === 0 ||
|
|
641
|
+
sample.end !== sample.kind ||
|
|
642
|
+
sample.end === sample.length,
|
|
643
|
+
)
|
|
644
|
+
expect(invalid, JSON.stringify(invalid.slice(0, 12), null, 2)).toEqual([])
|
|
645
|
+
|
|
646
|
+
const expected = `${WRITER_MARKER}${fixture}`
|
|
647
|
+
await waitForSaved(clients.maya)
|
|
648
|
+
await waitForText(clients.theo, expected)
|
|
649
|
+
expect(await sourceText(clients.maya)).toBe(expected)
|
|
650
|
+
expect(await clients.theo.locator(`footer strong`).textContent()).toBe(
|
|
651
|
+
`All changes saved`,
|
|
652
|
+
)
|
|
653
|
+
} finally {
|
|
654
|
+
await closeClients(clients)
|
|
655
|
+
}
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
test(`every painted remote caret remains on its semantic glyph`, async ({
|
|
659
|
+
browser,
|
|
660
|
+
}) => {
|
|
661
|
+
const clients = await openClients(browser)
|
|
662
|
+
try {
|
|
663
|
+
const marker = `CURSOR!!`
|
|
664
|
+
const fixture = await sourceText(clients.maya)
|
|
665
|
+
const rollout = fixture.lastIndexOf(`rollout`)
|
|
666
|
+
expect(rollout).toBeGreaterThan(0)
|
|
667
|
+
await moveCaretToText(clients.theo, `rollout`)
|
|
668
|
+
await clients.theo.keyboard.press(`ArrowRight`)
|
|
669
|
+
await clients.theo.keyboard.press(`ArrowLeft`)
|
|
670
|
+
|
|
671
|
+
const presence = clients.maya.locator(
|
|
672
|
+
`collaborator-presence[data-collaborator="Theo Brooks"]`,
|
|
673
|
+
)
|
|
674
|
+
await expect(presence).toHaveCount(1)
|
|
675
|
+
await expect(presence).toHaveAttribute(
|
|
676
|
+
`data-selection-end`,
|
|
677
|
+
String(rollout),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
const devtools = await clients.mayaContext.newCDPSession(clients.maya)
|
|
681
|
+
await devtools.send(`Network.enable`)
|
|
682
|
+
await devtools.send(`Network.emulateNetworkConditions`, {
|
|
683
|
+
downloadThroughput: 20_000_000,
|
|
684
|
+
latency: 100,
|
|
685
|
+
offline: false,
|
|
686
|
+
uploadThroughput: 20_000_000,
|
|
687
|
+
})
|
|
688
|
+
await devtools.send(`Emulation.setCPUThrottlingRate`, { rate: 3 })
|
|
689
|
+
await startVisibleCursorSampler(clients.maya, `Theo Brooks`, `rollout`)
|
|
690
|
+
await moveCaret(clients.maya, 0)
|
|
691
|
+
let typed = ``
|
|
692
|
+
for (const character of marker) {
|
|
693
|
+
await clients.maya.keyboard.type(character)
|
|
694
|
+
typed += character
|
|
695
|
+
await expect
|
|
696
|
+
.poll(() => sourceText(clients.maya), { timeout: 2_000 })
|
|
697
|
+
.toBe(`${typed}${fixture}`)
|
|
698
|
+
await clients.maya.waitForTimeout(150)
|
|
699
|
+
}
|
|
700
|
+
await clients.maya.waitForTimeout(750)
|
|
701
|
+
const samples = await stopVisibleCursorSampler(clients.maya)
|
|
702
|
+
expect(samples.length).toBeGreaterThan(30)
|
|
703
|
+
const expectedText = `${marker}${fixture}`
|
|
704
|
+
|
|
705
|
+
const invalid = samples.filter((sample) => {
|
|
706
|
+
if (
|
|
707
|
+
sample.overlayCount !== 1 ||
|
|
708
|
+
sample.caret === null ||
|
|
709
|
+
sample.expected === null ||
|
|
710
|
+
sample.selectionEnd !== sample.targetIndex
|
|
711
|
+
) {
|
|
712
|
+
return true
|
|
713
|
+
}
|
|
714
|
+
return (
|
|
715
|
+
Math.abs(sample.caret.left - sample.expected.left) > 1 ||
|
|
716
|
+
Math.abs(sample.caret.top - sample.expected.top) > 1
|
|
717
|
+
)
|
|
718
|
+
})
|
|
719
|
+
const terminal = invalid.filter(
|
|
720
|
+
(sample) =>
|
|
721
|
+
sample.caret !== null &&
|
|
722
|
+
sample.expected !== null &&
|
|
723
|
+
sample.caret.top > sample.expected.top + 50 &&
|
|
724
|
+
sample.selectionEnd !== null &&
|
|
725
|
+
sample.selectionEnd >= sample.textLength - 1,
|
|
726
|
+
)
|
|
727
|
+
expect(
|
|
728
|
+
invalid.length,
|
|
729
|
+
`A user can see every painted cursor, including a stale duplicate or one-frame terminal projection. ${invalid.length}/${samples.length} samples were invalid and ${terminal.length} painted at the document terminus. First failures:\n${JSON.stringify(invalid.slice(0, 6), null, 2)}`,
|
|
730
|
+
).toBe(0)
|
|
731
|
+
|
|
732
|
+
await waitForSaved(clients.maya)
|
|
733
|
+
await waitForText(clients.theo, expectedText)
|
|
734
|
+
expect(await sourceText(clients.maya)).toBe(expectedText)
|
|
735
|
+
} finally {
|
|
736
|
+
await closeClients(clients)
|
|
737
|
+
}
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
test(`an active remote writer never paints at the document terminus`, async ({
|
|
741
|
+
browser,
|
|
742
|
+
}) => {
|
|
743
|
+
const clients = await openClients(browser)
|
|
744
|
+
try {
|
|
745
|
+
const fixture = await sourceText(clients.maya)
|
|
746
|
+
const rollout = fixture.lastIndexOf(`rollout`)
|
|
747
|
+
expect(rollout).toBeGreaterThan(0)
|
|
748
|
+
await moveCaretToText(clients.theo, `rollout`)
|
|
749
|
+
await clients.theo.keyboard.press(`ArrowRight`)
|
|
750
|
+
await clients.theo.keyboard.press(`ArrowLeft`)
|
|
751
|
+
const presence = clients.maya.locator(
|
|
752
|
+
`collaborator-presence[data-collaborator="Theo Brooks"]`,
|
|
753
|
+
)
|
|
754
|
+
await expect(presence).toHaveCount(1)
|
|
755
|
+
await expect(presence).toHaveAttribute(
|
|
756
|
+
`data-selection-end`,
|
|
757
|
+
String(rollout),
|
|
758
|
+
)
|
|
759
|
+
const session = await presence.getAttribute(`data-session`)
|
|
760
|
+
expect(session).not.toBeNull()
|
|
761
|
+
if (session === null) throw new Error(`Theo's presence has no session.`)
|
|
762
|
+
|
|
763
|
+
const devtools = await clients.mayaContext.newCDPSession(clients.maya)
|
|
764
|
+
await devtools.send(`Network.enable`)
|
|
765
|
+
await devtools.send(`Network.emulateNetworkConditions`, {
|
|
766
|
+
downloadThroughput: 20_000_000,
|
|
767
|
+
latency: 55,
|
|
768
|
+
offline: false,
|
|
769
|
+
uploadThroughput: 20_000_000,
|
|
770
|
+
})
|
|
771
|
+
await devtools.send(`Emulation.setCPUThrottlingRate`, { rate: 3 })
|
|
772
|
+
|
|
773
|
+
await startCursorSampler(clients.maya, session, `rollout`)
|
|
774
|
+
let typed = ``
|
|
775
|
+
for (const character of `[ACTIVE-THEO]`) {
|
|
776
|
+
await clients.theo.keyboard.type(character)
|
|
777
|
+
typed += character
|
|
778
|
+
const expected = `${fixture.slice(0, rollout)}${typed}${fixture.slice(rollout)}`
|
|
779
|
+
expect(await sourceText(clients.theo)).toBe(expected)
|
|
780
|
+
await clients.theo.waitForTimeout(120)
|
|
781
|
+
expect(await sourceText(clients.theo)).toBe(expected)
|
|
782
|
+
}
|
|
783
|
+
await clients.theo.waitForTimeout(600)
|
|
784
|
+
const samples = await stopCursorSampler(clients.maya)
|
|
785
|
+
expect(samples.length).toBeGreaterThan(`[ACTIVE-THEO]`.length)
|
|
786
|
+
const terminal = samples.filter(
|
|
787
|
+
(sample) =>
|
|
788
|
+
sample.end !== null &&
|
|
789
|
+
sample.kind >= 0 &&
|
|
790
|
+
sample.end === sample.length,
|
|
791
|
+
)
|
|
792
|
+
expect(terminal, JSON.stringify(terminal, null, 2)).toEqual([])
|
|
793
|
+
const expected = `${fixture.slice(0, rollout)}[ACTIVE-THEO]${fixture.slice(rollout)}`
|
|
794
|
+
await Promise.all([
|
|
795
|
+
waitForText(clients.maya, expected),
|
|
796
|
+
waitForText(clients.theo, expected),
|
|
797
|
+
])
|
|
798
|
+
await expect(presence).toHaveAttribute(
|
|
799
|
+
`data-selection-end`,
|
|
800
|
+
String(rollout + `[ACTIVE-THEO]`.length),
|
|
801
|
+
)
|
|
802
|
+
} finally {
|
|
803
|
+
await closeClients(clients)
|
|
804
|
+
}
|
|
805
|
+
})
|
|
806
|
+
})
|