@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,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IncrementalMarkdownParser,
|
|
3
|
+
type MarkdownSourceBlock,
|
|
4
|
+
} from "../src/incremental-markdown.ts"
|
|
5
|
+
import { markdownVirtualWindow } from "../src/virtualization.ts"
|
|
6
|
+
|
|
7
|
+
const block = (
|
|
8
|
+
text: string,
|
|
9
|
+
index: number,
|
|
10
|
+
key = `run:${index}`,
|
|
11
|
+
): MarkdownSourceBlock => ({
|
|
12
|
+
anchor: { affinity: `right`, offset: index, runId: `run` },
|
|
13
|
+
end: index + text.length,
|
|
14
|
+
key,
|
|
15
|
+
start: index,
|
|
16
|
+
text,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe(`incremental Markdown rendering`, () => {
|
|
20
|
+
test(`propagates fences to a stable boundary and reuses the untouched suffix`, async () => {
|
|
21
|
+
const parser = new IncrementalMarkdownParser()
|
|
22
|
+
const first = [
|
|
23
|
+
block(`# Heading`, 0),
|
|
24
|
+
block(`paragraph`, 10),
|
|
25
|
+
block(`\`\`\`ts`, 20),
|
|
26
|
+
block(`const x = 1`, 30),
|
|
27
|
+
block(`\`\`\``, 45),
|
|
28
|
+
block(`tail`, 50),
|
|
29
|
+
]
|
|
30
|
+
const initial = await parser.parse(first, { yieldAfterUtf16Units: 8 })
|
|
31
|
+
expect(initial.blocks.map(({ kind }) => kind)).toEqual([
|
|
32
|
+
`heading`,
|
|
33
|
+
`paragraph`,
|
|
34
|
+
`code`,
|
|
35
|
+
`paragraph`,
|
|
36
|
+
])
|
|
37
|
+
expect(initial.instrumentation.parsedBlocks).toBe(6)
|
|
38
|
+
|
|
39
|
+
const edited = [...first]
|
|
40
|
+
edited[3] = block(`const x = 2`, 30)
|
|
41
|
+
const reparsed = await parser.parse(edited, { yieldAfterUtf16Units: 8 })
|
|
42
|
+
expect(reparsed.blocks.find(({ kind }) => kind === `code`)?.text).toBe(
|
|
43
|
+
`const x = 2`,
|
|
44
|
+
)
|
|
45
|
+
expect(reparsed.instrumentation.parsedBlocks).toBeLessThan(4)
|
|
46
|
+
expect(reparsed.instrumentation.reusedBlocks).toBeGreaterThan(2)
|
|
47
|
+
expect(reparsed.instrumentation.stableBoundaryIndex).toBe(5)
|
|
48
|
+
expect(reparsed.instrumentation.scannedUtf16Units).toBeLessThan(
|
|
49
|
+
first.reduce((sum, item) => sum + item.text.length, 0),
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test(`cancels stale expensive work off the caller's input turn`, async () => {
|
|
54
|
+
const parser = new IncrementalMarkdownParser()
|
|
55
|
+
const large = Array.from({ length: 2_000 }, (_, index) =>
|
|
56
|
+
block(`paragraph ${index} ${`x`.repeat(64)}`, index * 80),
|
|
57
|
+
)
|
|
58
|
+
const stale = parser.parse(large, { yieldAfterUtf16Units: 128 })
|
|
59
|
+
const latest = parser.parse([block(`# Latest`, 0)], {
|
|
60
|
+
yieldAfterUtf16Units: 128,
|
|
61
|
+
})
|
|
62
|
+
expect((await stale).instrumentation.canceled).toBe(true)
|
|
63
|
+
expect((await latest).blocks).toMatchObject([
|
|
64
|
+
{ kind: `heading`, text: `Latest` },
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test(`honors a cancelation signal that was already aborted`, async () => {
|
|
69
|
+
const parser = new IncrementalMarkdownParser()
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
controller.abort()
|
|
72
|
+
const result = await parser.parse([block(`# Never parsed`, 0)], {
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
})
|
|
75
|
+
expect(result.blocks).toEqual([])
|
|
76
|
+
expect(result.instrumentation).toMatchObject({
|
|
77
|
+
canceled: true,
|
|
78
|
+
parsedBlocks: 0,
|
|
79
|
+
scannedUtf16Units: 0,
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test(`keeps source and preview windows bounded at a deterministic 50 MB length`, () => {
|
|
84
|
+
const totalUtf16Units = 56_384_800
|
|
85
|
+
for (const scrollTop of [0, 1_000_000, 15_000_000]) {
|
|
86
|
+
const window = markdownVirtualWindow(
|
|
87
|
+
{ height: 720, scrollTop },
|
|
88
|
+
{
|
|
89
|
+
averageUtf16UnitsPerRow: 88,
|
|
90
|
+
overscanRows: 24,
|
|
91
|
+
rowHeight: 24,
|
|
92
|
+
totalUtf16Units,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
expect(window.range.end - window.range.start).toBeLessThan(65_536)
|
|
96
|
+
expect(window.topSpacer + window.bottomSpacer).toBeLessThan(
|
|
97
|
+
totalUtf16Units,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
})
|
package/tsconfig.app.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
4
4
|
"target": "ES2024",
|
|
5
5
|
"useDefineForClassFields": true,
|
|
6
|
-
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
6
|
+
"lib": ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"],
|
|
7
7
|
"module": "ESNext",
|
|
8
8
|
"types": ["vite/client", "lasertag/css-modules", "lasertag/react-jsx"],
|
|
9
9
|
"skipLibCheck": true,
|
package/tsconfig.node.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
4
4
|
"target": "ES2024",
|
|
5
|
-
"lib": ["ES2024"],
|
|
5
|
+
"lib": ["ES2024", "ESNext.Disposable"],
|
|
6
6
|
"module": "ESNext",
|
|
7
7
|
"types": ["node"],
|
|
8
8
|
"skipLibCheck": true,
|
|
@@ -18,5 +18,11 @@
|
|
|
18
18
|
"noFallthroughCasesInSwitch": true,
|
|
19
19
|
"noUncheckedSideEffectImports": true
|
|
20
20
|
},
|
|
21
|
-
"include": [
|
|
21
|
+
"include": [
|
|
22
|
+
"node",
|
|
23
|
+
"vite.config.ts",
|
|
24
|
+
"vitest.config.ts",
|
|
25
|
+
"playwright.config.ts",
|
|
26
|
+
"eslint.config.ts"
|
|
27
|
+
]
|
|
22
28
|
}
|
package/tsconfig.test.json
CHANGED
package/vite.config.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
import react from "@vitejs/plugin-react"
|
|
2
2
|
import { defineConfig } from "vite"
|
|
3
3
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
const proxyBackend = () => ({
|
|
4
|
+
const proxyBackend = (backendUrl: string) => ({
|
|
7
5
|
[`/health`]: {
|
|
8
6
|
changeOrigin: true,
|
|
9
|
-
target:
|
|
7
|
+
target: backendUrl,
|
|
10
8
|
},
|
|
11
9
|
[`/socket.io`]: {
|
|
12
10
|
changeOrigin: true,
|
|
13
|
-
target:
|
|
11
|
+
target: backendUrl,
|
|
14
12
|
ws: true,
|
|
15
13
|
},
|
|
16
14
|
})
|
|
17
15
|
|
|
18
|
-
export default defineConfig({
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
export default defineConfig(({ mode }) => {
|
|
17
|
+
const backendUrl =
|
|
18
|
+
mode === `browser-conformance`
|
|
19
|
+
? `http://127.0.0.1:3027`
|
|
20
|
+
: (process.env.VITE_BACKEND_URL ?? `http://localhost:3000`)
|
|
21
|
+
return {
|
|
22
|
+
plugins: [react()],
|
|
23
|
+
preview: { proxy: proxyBackend(backendUrl) },
|
|
24
|
+
server: { proxy: proxyBackend(backendUrl) },
|
|
25
|
+
}
|
|
22
26
|
})
|
package/node/mosaic-atom.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
MosaicTextOperation,
|
|
3
|
-
MosaicTextSelection,
|
|
4
|
-
MosaicTextView,
|
|
5
|
-
} from "atom.io/realtime"
|
|
6
|
-
import { defineMosaicAtomRegistration } from "atom.io/realtime-server"
|
|
7
|
-
import { z } from "zod"
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
Markdown,
|
|
11
|
-
markdownAtom,
|
|
12
|
-
type MarkdownPresence,
|
|
13
|
-
} from "../src/collaboration/mosaic.ts"
|
|
14
|
-
|
|
15
|
-
const idSchema = z.string().min(1).max(512)
|
|
16
|
-
const nullableIdSchema = idSchema.nullable()
|
|
17
|
-
|
|
18
|
-
const relativePositionSchema = z
|
|
19
|
-
.object({
|
|
20
|
-
affinity: z.enum([`left`, `right`]),
|
|
21
|
-
leftId: nullableIdSchema,
|
|
22
|
-
rightId: nullableIdSchema,
|
|
23
|
-
})
|
|
24
|
-
.strict()
|
|
25
|
-
|
|
26
|
-
const selectionSchema = z
|
|
27
|
-
.object({
|
|
28
|
-
anchor: relativePositionSchema,
|
|
29
|
-
head: relativePositionSchema,
|
|
30
|
-
})
|
|
31
|
-
.strict()
|
|
32
|
-
|
|
33
|
-
const operationSchema = z.discriminatedUnion(`type`, [
|
|
34
|
-
z
|
|
35
|
-
.object({
|
|
36
|
-
deletedIds: z.array(idSchema).max(200_000),
|
|
37
|
-
inserted: z
|
|
38
|
-
.array(
|
|
39
|
-
z
|
|
40
|
-
.object({
|
|
41
|
-
after: nullableIdSchema,
|
|
42
|
-
before: nullableIdSchema,
|
|
43
|
-
id: idSchema,
|
|
44
|
-
value: z.string().max(1_024),
|
|
45
|
-
})
|
|
46
|
-
.strict(),
|
|
47
|
-
)
|
|
48
|
-
.max(200_000),
|
|
49
|
-
type: z.literal(`edit`),
|
|
50
|
-
})
|
|
51
|
-
.strict(),
|
|
52
|
-
z
|
|
53
|
-
.object({
|
|
54
|
-
mode: z.enum([`redo`, `undo`]),
|
|
55
|
-
targetOperationIds: z.array(idSchema).min(1).max(10_000),
|
|
56
|
-
type: z.literal(`history`),
|
|
57
|
-
})
|
|
58
|
-
.strict(),
|
|
59
|
-
]) satisfies z.ZodType<MosaicTextOperation>
|
|
60
|
-
|
|
61
|
-
const presenceSchema = z
|
|
62
|
-
.object({
|
|
63
|
-
color: z.string().min(1).max(64),
|
|
64
|
-
lastActiveAt: z.number().finite().nonnegative(),
|
|
65
|
-
name: z.string().min(1).max(128),
|
|
66
|
-
selection: selectionSchema.nullable(),
|
|
67
|
-
})
|
|
68
|
-
.strict() satisfies z.ZodType<MarkdownPresence>
|
|
69
|
-
|
|
70
|
-
const selectionResolvesWithin = (
|
|
71
|
-
document: MosaicTextView,
|
|
72
|
-
selection: MosaicTextSelection,
|
|
73
|
-
): boolean =>
|
|
74
|
-
[selection.anchor, selection.head].every((position) => {
|
|
75
|
-
const offset = document.resolvePosition(position)
|
|
76
|
-
return offset >= 0 && offset <= document.length
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
export const markdownAtomRegistration = defineMosaicAtomRegistration({
|
|
80
|
-
checkpointEvery: 128,
|
|
81
|
-
class: Markdown,
|
|
82
|
-
operationSchema,
|
|
83
|
-
presenceSchema,
|
|
84
|
-
target: markdownAtom,
|
|
85
|
-
validatePresence: (presence, { view }) =>
|
|
86
|
-
presence.selection === null ||
|
|
87
|
-
selectionResolvesWithin(view, presence.selection),
|
|
88
|
-
})
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { mutableAtom, selector } from "atom.io"
|
|
2
|
-
import type { MosaicTextSelection } from "atom.io/realtime"
|
|
3
|
-
import { mosaicText } from "atom.io/realtime"
|
|
4
|
-
|
|
5
|
-
import { INITIAL_MARKDOWN } from "../initial-markdown.ts"
|
|
6
|
-
|
|
7
|
-
export type MarkdownPresence = {
|
|
8
|
-
readonly color: string
|
|
9
|
-
readonly lastActiveAt: number
|
|
10
|
-
readonly name: string
|
|
11
|
-
readonly selection: MosaicTextSelection | null
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const Markdown = mosaicText({
|
|
15
|
-
initialText: INITIAL_MARKDOWN,
|
|
16
|
-
maximumGraphemes: 200_000,
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
/** The collaborative document is an ordinary mutable atom. */
|
|
20
|
-
export const markdownAtom = mutableAtom<InstanceType<typeof Markdown>>({
|
|
21
|
-
key: `markdown`,
|
|
22
|
-
class: Markdown,
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
/** Mosaic views participate in the state graph like every other atom value. */
|
|
26
|
-
export const markdownCharacterCountSelector = selector<number>({
|
|
27
|
-
key: `markdownCharacterCount`,
|
|
28
|
-
get: ({ get }) => get(markdownAtom).length,
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
export const markdownWordCountSelector = selector<number>({
|
|
32
|
-
key: `markdownWordCount`,
|
|
33
|
-
get: ({ get }) => {
|
|
34
|
-
const text = get(markdownAtom).text.trim()
|
|
35
|
-
return text === `` ? 0 : text.split(/\s+/u).length
|
|
36
|
-
},
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
export function lineAndColumnAt(
|
|
40
|
-
text: string,
|
|
41
|
-
offset: number,
|
|
42
|
-
): { column: number; line: number } {
|
|
43
|
-
const before = text.slice(0, offset)
|
|
44
|
-
const lines = before.split(`\n`)
|
|
45
|
-
return { column: lines.at(-1)?.length ?? 0, line: lines.length - 1 }
|
|
46
|
-
}
|