@startsimpli/ui 0.4.143 → 0.4.144
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/package.json +3 -3
- package/src/components/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +687 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +604 -0
- package/src/components/track-changes/final-text.ts +54 -0
- package/src/components/track-changes/http-client.ts +109 -0
- package/src/components/track-changes/index.ts +18 -2
- package/src/components/track-changes/normalize.ts +29 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +79 -1
- package/src/components/track-changes/useTrackChanges.ts +196 -3
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail, the Read view, the Final/Original views and the composer
|
|
3
|
+
* (bd startsim-whwxd.3).
|
|
4
|
+
*
|
|
5
|
+
* What must hold: open suggestions and threads list in document order and are
|
|
6
|
+
* reachable by keyboard; accept and reject exist only for someone who may
|
|
7
|
+
* edit; a view-only reviewer can still suggest, comment, reply and resolve;
|
|
8
|
+
* marks in the RENDERED text sit at their source offsets and are never drawn
|
|
9
|
+
* at a guessed place; Final applies the open suggestions and names the ones it
|
|
10
|
+
* could not; a read for another version says so instead of going quiet; and
|
|
11
|
+
* a mention is a sub carried in-app, never an email.
|
|
12
|
+
*/
|
|
13
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
14
|
+
import { renderHook } from '@testing-library/react'
|
|
15
|
+
|
|
16
|
+
// react-markdown ships ESM that this jest config does not transform, so the
|
|
17
|
+
// renderer is stood in by one that runs the SAME rehype plugins over a
|
|
18
|
+
// one-paragraph tree. The transform itself is tested on real hast below.
|
|
19
|
+
jest.mock('../../blog/MarkdownRenderer', () => {
|
|
20
|
+
const { plainTree } = jest.requireActual('../read-marks')
|
|
21
|
+
const { renderHast } = jest.requireActual('../TrackedReadView')
|
|
22
|
+
return {
|
|
23
|
+
MarkdownRenderer: ({ content, rehypePlugins }: { content: string; rehypePlugins?: Array<() => (t: unknown) => void> }) => {
|
|
24
|
+
const tree = plainTree(content)
|
|
25
|
+
for (const p of rehypePlugins ?? []) p()(tree)
|
|
26
|
+
return <div data-testid="md">{renderHast(tree)}</div>
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
import { ChangesRail, mentionQuery, mentionsIn } from '../ChangesRail'
|
|
32
|
+
import { applyOpenSuggestions } from '../final-text'
|
|
33
|
+
import { createHttpTrackChangesClient, TrackChangesHttpError } from '../http-client'
|
|
34
|
+
import { alignToSource, markTree, plainTree, sourceOffsetOf, type HastElement, type HastRoot } from '../read-marks'
|
|
35
|
+
import { TrackedField } from '../TrackedField'
|
|
36
|
+
import { renderHast, TrackedReadView } from '../TrackedReadView'
|
|
37
|
+
import { useTrackChanges } from '../useTrackChanges'
|
|
38
|
+
import type { CommentThread, Suggestion, TrackActor, TrackChangesClient } from '../types'
|
|
39
|
+
|
|
40
|
+
const ada: TrackActor = { sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' }
|
|
41
|
+
const bo: TrackActor = { sub: 'bo', label: 'bo@x.io', kind: 'person', kindSource: 'claim' }
|
|
42
|
+
const ghost: TrackActor = { sub: 'g', label: 'g@x.io', kind: 'unknown' }
|
|
43
|
+
|
|
44
|
+
const TEXT = 'Hello world. A second sentence. The end.'
|
|
45
|
+
|
|
46
|
+
function sugg(id: string, quote: string, replacement: string, author: TrackActor = bo, extra: Partial<Suggestion> = {}): Suggestion {
|
|
47
|
+
const from = TEXT.indexOf(quote)
|
|
48
|
+
return { id, setId: 's', from, to: from + quote.length, quote, replacement, status: 'open', anchorState: 'mapped', author, createdAt: null, ...extra }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function insertAt(id: string, at: number, replacement: string): Suggestion {
|
|
52
|
+
return { id, setId: 's', from: at, to: at, quote: '', replacement, status: 'open', anchorState: 'mapped', author: bo, createdAt: null }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function thread(id: string, quote: string | null, body: string, extra: Partial<CommentThread> = {}): CommentThread {
|
|
56
|
+
const from = quote ? TEXT.indexOf(quote) : null
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
field: 'blog',
|
|
60
|
+
resolution: null,
|
|
61
|
+
from,
|
|
62
|
+
to: from == null ? null : from + quote!.length,
|
|
63
|
+
quote: quote ?? '',
|
|
64
|
+
anchorState: 'mapped',
|
|
65
|
+
resolved: false,
|
|
66
|
+
redacted: false,
|
|
67
|
+
comments: [{ id: `${id}-c1`, body, deleted: false, author: ada, createdAt: null, mentions: [], editedAt: null }],
|
|
68
|
+
...extra,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* -------------------------------------------------------------------------- */
|
|
73
|
+
/* Final / Original */
|
|
74
|
+
/* -------------------------------------------------------------------------- */
|
|
75
|
+
|
|
76
|
+
describe('applyOpenSuggestions (the Final view)', () => {
|
|
77
|
+
it('applies every open, placed suggestion in document order', () => {
|
|
78
|
+
const r = applyOpenSuggestions(TEXT, [sugg('b', 'second', 'third'), sugg('a', 'Hello', 'Hi')])
|
|
79
|
+
expect(r.text).toBe('Hi world. A third sentence. The end.')
|
|
80
|
+
expect(r.applied).toEqual(['a', 'b'])
|
|
81
|
+
expect(r.skipped).toEqual([])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('skips and names an overlapping one rather than merging by guesswork', () => {
|
|
85
|
+
const r = applyOpenSuggestions(TEXT, [sugg('a', 'Hello world', 'Hey'), sugg('b', 'world', 'earth')])
|
|
86
|
+
expect(r.text).toBe('Hey. A second sentence. The end.')
|
|
87
|
+
expect(r.skipped).toEqual(['b'])
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves out orphaned, closed and quote-mismatched suggestions', () => {
|
|
91
|
+
const r = applyOpenSuggestions(TEXT, [
|
|
92
|
+
sugg('o', 'Hello', 'X', bo, { anchorState: 'orphaned' }),
|
|
93
|
+
sugg('c', 'world', 'Y', bo, { status: 'rejected' }),
|
|
94
|
+
{ ...sugg('m', 'The end', 'Z'), quote: 'Not this' },
|
|
95
|
+
])
|
|
96
|
+
expect(r.text).toBe(TEXT)
|
|
97
|
+
expect(r.applied).toEqual([])
|
|
98
|
+
expect(r.skipped).toEqual(['m'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('applies a pure insert', () => {
|
|
102
|
+
expect(applyOpenSuggestions(TEXT, [insertAt('i', 5, ',')]).text).toBe('Hello, world. A second sentence. The end.')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
/* -------------------------------------------------------------------------- */
|
|
107
|
+
/* Read-view marks on hast */
|
|
108
|
+
/* -------------------------------------------------------------------------- */
|
|
109
|
+
|
|
110
|
+
function text(value: string, start: number, end: number) {
|
|
111
|
+
return { type: 'text' as const, value, position: { start: { offset: start }, end: { offset: end } } }
|
|
112
|
+
}
|
|
113
|
+
function el(tagName: string, children: unknown[]): HastElement {
|
|
114
|
+
return { type: 'element', tagName, properties: {}, children: children as HastElement['children'] }
|
|
115
|
+
}
|
|
116
|
+
function html(tree: HastRoot) {
|
|
117
|
+
const { container } = render(<div>{renderHast(tree)}</div>)
|
|
118
|
+
return container
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('alignToSource', () => {
|
|
122
|
+
it('maps character for character when the text is its source slice', () => {
|
|
123
|
+
expect(alignToSource('ab', 'xxab', 2, 4)).toEqual([2, 3])
|
|
124
|
+
})
|
|
125
|
+
it('skips an escape backslash', () => {
|
|
126
|
+
expect(alignToSource('a_b', 'a\\_b', 0, 4)).toEqual([0, 2, 3])
|
|
127
|
+
})
|
|
128
|
+
it('aligns an entity that decodes to a character of its own spelling', () => {
|
|
129
|
+
expect(alignToSource('&', '&', 0, 5)).toEqual([0])
|
|
130
|
+
})
|
|
131
|
+
it('refuses a node it cannot prove (an entity whose character is not in its source)', () => {
|
|
132
|
+
expect(alignToSource("'", ''', 0, 5)).toBeNull()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('markTree', () => {
|
|
137
|
+
it('strikes a suggestion, draws its insert after it, and keeps the source offset on every run', () => {
|
|
138
|
+
const src = 'Hello world.'
|
|
139
|
+
const tree: HastRoot = { type: 'root', children: [el('p', [text(src, 0, src.length)])] }
|
|
140
|
+
const { placed } = markTree(tree, src, [{ id: 's1', kind: 'suggestion', from: 6, to: 11, title: 'Suggestion', insert: 'earth' }])
|
|
141
|
+
const c = html(tree)
|
|
142
|
+
expect(placed.has('s1')).toBe(true)
|
|
143
|
+
expect(c.querySelector('del')?.textContent).toBe('world')
|
|
144
|
+
expect(c.querySelector('del')?.getAttribute('data-tc-id')).toBe('s1')
|
|
145
|
+
expect(c.querySelector('ins')?.textContent).toBe('earth')
|
|
146
|
+
// The insert follows the struck text.
|
|
147
|
+
expect(c.textContent).toBe('Hello worldearth.')
|
|
148
|
+
const runs = Array.from(c.querySelectorAll('[data-src-from]')).map((n) => n.getAttribute('data-src-from'))
|
|
149
|
+
expect(runs).toEqual(['0', '6', '11'])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('draws a range that crosses inline formatting in every text node it covers', () => {
|
|
153
|
+
// "a **bold** b": the text nodes are "a ", "bold", " b" at 0-2, 4-8, 10-12.
|
|
154
|
+
const src = 'a **bold** b'
|
|
155
|
+
const tree: HastRoot = {
|
|
156
|
+
type: 'root',
|
|
157
|
+
children: [el('p', [text('a ', 0, 2), el('strong', [text('bold', 4, 8)]), text(' b', 10, 12)])],
|
|
158
|
+
}
|
|
159
|
+
const { placed } = markTree(tree, src, [{ id: 'c1', kind: 'comment', from: 0, to: 12, title: 'Comment' }])
|
|
160
|
+
const c = html(tree)
|
|
161
|
+
expect(placed.has('c1')).toBe(true)
|
|
162
|
+
expect(Array.from(c.querySelectorAll('mark')).map((m) => m.textContent)).toEqual(['a ', 'bold', ' b'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('places marks correctly after an escape', () => {
|
|
166
|
+
const src = 'a \\_b c'
|
|
167
|
+
// rendered "a _b c"
|
|
168
|
+
const tree: HastRoot = { type: 'root', children: [el('p', [text('a _b c', 0, 7)])] }
|
|
169
|
+
markTree(tree, src, [{ id: 'c', kind: 'comment', from: 5, to: 7, title: 'x' }]) // " c" -> source 5..7
|
|
170
|
+
const c = html(tree)
|
|
171
|
+
expect(c.querySelector('mark')?.textContent).toBe(' c')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('never draws inside code, and reports the mark unplaced', () => {
|
|
175
|
+
const src = 'see `code` here'
|
|
176
|
+
const tree: HastRoot = {
|
|
177
|
+
type: 'root',
|
|
178
|
+
children: [el('p', [text('see ', 0, 4), el('code', [text('code', 5, 9)]), text(' here', 10, 15)])],
|
|
179
|
+
}
|
|
180
|
+
const { placed } = markTree(tree, src, [{ id: 's', kind: 'suggestion', from: 5, to: 9, title: 'x', insert: 'y' }])
|
|
181
|
+
expect(placed.has('s')).toBe(false)
|
|
182
|
+
expect(html(tree).querySelector('del')).toBeNull()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('leaves a node it cannot align untouched rather than painting a guess', () => {
|
|
186
|
+
const src = 'it's'
|
|
187
|
+
const tree: HastRoot = { type: 'root', children: [el('p', [text("it's", 0, 8)])] }
|
|
188
|
+
const { placed } = markTree(tree, src, [{ id: 'c', kind: 'comment', from: 0, to: 2, title: 'x' }])
|
|
189
|
+
expect(placed.size).toBe(0)
|
|
190
|
+
expect(html(tree).querySelector('mark')).toBeNull()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('maps a DOM point back to its source offset', () => {
|
|
194
|
+
const tree = plainTree(TEXT)
|
|
195
|
+
markTree(tree, TEXT, [{ id: 'c', kind: 'comment', from: 6, to: 11, title: 'x' }])
|
|
196
|
+
const c = html(tree)
|
|
197
|
+
const mark = c.querySelector('mark')!
|
|
198
|
+
const node = mark.querySelector('[data-src-from]')!.firstChild!
|
|
199
|
+
expect(sourceOffsetOf(node, 2)).toBe(8)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
/* -------------------------------------------------------------------------- */
|
|
204
|
+
/* TrackedReadView */
|
|
205
|
+
/* -------------------------------------------------------------------------- */
|
|
206
|
+
|
|
207
|
+
describe('TrackedReadView', () => {
|
|
208
|
+
it('draws suggestions, inserts and comments over plain text, and a click selects the mark', () => {
|
|
209
|
+
const onActivate = jest.fn()
|
|
210
|
+
render(
|
|
211
|
+
<TrackedReadView
|
|
212
|
+
source={TEXT}
|
|
213
|
+
language="plain"
|
|
214
|
+
suggestions={[sugg('s1', 'second', 'third')]}
|
|
215
|
+
threads={[thread('t1', 'The end', 'Too abrupt')]}
|
|
216
|
+
onActivate={onActivate}
|
|
217
|
+
/>,
|
|
218
|
+
)
|
|
219
|
+
const view = screen.getByTestId('tracked-read-view')
|
|
220
|
+
expect(view.querySelector('del')?.textContent).toBe('second')
|
|
221
|
+
expect(view.querySelector('ins')?.textContent).toBe('third')
|
|
222
|
+
expect(view.querySelector('mark')?.textContent).toBe('The end')
|
|
223
|
+
fireEvent.click(view.querySelector('mark')!)
|
|
224
|
+
expect(onActivate).toHaveBeenCalledWith('t1')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('hides changes when asked, and shows authorship only when asked', () => {
|
|
228
|
+
const blame = [{ id: 'blame:0', from: 0, to: TEXT.length, author: ada, unknownReason: null, reason: null, version: 1, at: null, via: 'edit' as const, acceptedBy: null, restoredFrom: null }]
|
|
229
|
+
const { rerender } = render(
|
|
230
|
+
<TrackedReadView source={TEXT} language="plain" suggestions={[sugg('s1', 'second', 'third')]} blame={blame} showChanges={false} />,
|
|
231
|
+
)
|
|
232
|
+
const view = screen.getByTestId('tracked-read-view')
|
|
233
|
+
expect(view.querySelector('del')).toBeNull()
|
|
234
|
+
expect(view.querySelector('.tc-read-blame')).toBeNull()
|
|
235
|
+
rerender(<TrackedReadView source={TEXT} language="plain" blame={blame} showAuthors />)
|
|
236
|
+
expect(view.querySelector('.tc-read-blame')?.getAttribute('title')).toMatch(/Written by ada@x\.io/)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('reports a text selection as a source anchor', () => {
|
|
240
|
+
const onSel = jest.fn()
|
|
241
|
+
render(<TrackedReadView source={TEXT} language="plain" onSelectionChange={onSel} />)
|
|
242
|
+
const run = screen.getByTestId('tracked-read-view').querySelector('[data-src-from]')!.firstChild!
|
|
243
|
+
const range = document.createRange()
|
|
244
|
+
range.setStart(run, 6)
|
|
245
|
+
range.setEnd(run, 11)
|
|
246
|
+
const sel = window.getSelection()!
|
|
247
|
+
sel.removeAllRanges()
|
|
248
|
+
sel.addRange(range)
|
|
249
|
+
fireEvent.mouseUp(screen.getByTestId('tracked-read-view'))
|
|
250
|
+
expect(onSel).toHaveBeenLastCalledWith(expect.objectContaining({ from: 6, to: 11, quote: 'world' }))
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('goes through the markdown renderer with the same marks', () => {
|
|
254
|
+
render(<TrackedReadView source={TEXT} language="markdown" suggestions={[sugg('s1', 'second', 'third')]} />)
|
|
255
|
+
expect(screen.getByTestId('md').querySelector('del')?.textContent).toBe('second')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
/* -------------------------------------------------------------------------- */
|
|
260
|
+
/* ChangesRail */
|
|
261
|
+
/* -------------------------------------------------------------------------- */
|
|
262
|
+
|
|
263
|
+
describe('ChangesRail', () => {
|
|
264
|
+
const base = {
|
|
265
|
+
fieldLabel: 'Blog',
|
|
266
|
+
currentActorSub: 'ada',
|
|
267
|
+
suggestions: [sugg('s2', 'The end', 'Fin'), sugg('s1', 'Hello', 'Hi')],
|
|
268
|
+
threads: [thread('t1', 'second', 'Why second?')],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it('lists open items in document order, each named in words with person/machine/unknown', () => {
|
|
272
|
+
render(<ChangesRail {...base} canAccept suggestions={[...base.suggestions, sugg('s3', 'world', 'earth', ghost)]} />)
|
|
273
|
+
const items = screen.getAllByRole('listitem').filter((li) => li.hasAttribute('data-rail-item'))
|
|
274
|
+
expect(items.map((i) => i.getAttribute('data-item-id'))).toEqual(['s1', 's3', 't1', 's2'])
|
|
275
|
+
expect(items[0]).toHaveAttribute('aria-label', expect.stringMatching(/Suggestion by bo@x\.io.*Written by a person/))
|
|
276
|
+
expect(items[1]).toHaveAttribute('aria-label', expect.stringMatching(/not recorded/))
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('offers accept and reject only to someone who may edit', () => {
|
|
280
|
+
const { rerender } = render(<ChangesRail {...base} canAccept={false} onAccept={jest.fn()} onReject={jest.fn()} onComment={jest.fn()} />)
|
|
281
|
+
expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()
|
|
282
|
+
expect(screen.queryByRole('button', { name: 'Reject' })).toBeNull()
|
|
283
|
+
// ...but a view-only reviewer still comments.
|
|
284
|
+
expect(screen.getByLabelText('New comment on Blog')).toBeInTheDocument()
|
|
285
|
+
rerender(<ChangesRail {...base} canAccept onAccept={jest.fn()} onReject={jest.fn()} />)
|
|
286
|
+
expect(screen.getAllByRole('button', { name: 'Accept' })).toHaveLength(2)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('accepts by button and by the A key on a focused item, rejects by R', async () => {
|
|
290
|
+
const onAccept = jest.fn().mockResolvedValue(undefined)
|
|
291
|
+
const onReject = jest.fn().mockResolvedValue(undefined)
|
|
292
|
+
render(<ChangesRail {...base} canAccept onAccept={onAccept} onReject={onReject} />)
|
|
293
|
+
const first = screen.getAllByRole('listitem').find((li) => li.getAttribute('data-item-id') === 's1')!
|
|
294
|
+
fireEvent.click(within(first).getByRole('button', { name: 'Accept' }))
|
|
295
|
+
await waitFor(() => expect(onAccept).toHaveBeenCalledWith(['s1']))
|
|
296
|
+
const last = screen.getAllByRole('listitem').find((li) => li.getAttribute('data-item-id') === 's2')!
|
|
297
|
+
fireEvent.keyDown(last, { key: 'a' })
|
|
298
|
+
await waitFor(() => expect(onAccept).toHaveBeenLastCalledWith(['s2']))
|
|
299
|
+
fireEvent.keyDown(last, { key: 'r' })
|
|
300
|
+
await waitFor(() => expect(onReject).toHaveBeenCalledWith(['s2']))
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('moves between items with the arrow keys and shows the item in the text on Enter', () => {
|
|
304
|
+
const onFocusItem = jest.fn()
|
|
305
|
+
render(<ChangesRail {...base} canAccept onFocusItem={onFocusItem} />)
|
|
306
|
+
const items = screen.getAllByRole('listitem').filter((li) => li.hasAttribute('data-rail-item'))
|
|
307
|
+
items[0].focus()
|
|
308
|
+
fireEvent.keyDown(items[0], { key: 'ArrowDown' })
|
|
309
|
+
expect(document.activeElement).toBe(items[1])
|
|
310
|
+
fireEvent.keyDown(items[1], { key: 'Enter' })
|
|
311
|
+
expect(onFocusItem).toHaveBeenLastCalledWith('t1')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('shows the server sentence when an accept fails, and keeps the item', async () => {
|
|
315
|
+
const onAccept = jest.fn().mockRejectedValue(new Error('Could not accept this suggestion: the text under this suggestion changed.'))
|
|
316
|
+
render(<ChangesRail {...base} canAccept onAccept={onAccept} />)
|
|
317
|
+
fireEvent.click(screen.getAllByRole('button', { name: 'Accept' })[0])
|
|
318
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(/text under this suggestion changed/)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('lets only the author withdraw', () => {
|
|
322
|
+
render(<ChangesRail {...base} suggestions={[sugg('mine', 'Hello', 'Hi', ada), sugg('theirs', 'world', 'earth', bo)]} canAccept={false} onWithdraw={jest.fn()} />)
|
|
323
|
+
expect(screen.getAllByRole('button', { name: 'Withdraw' })).toHaveLength(1)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('replies, resolves, and lists resolved threads to reopen', async () => {
|
|
327
|
+
const onReply = jest.fn().mockResolvedValue(undefined)
|
|
328
|
+
const onResolve = jest.fn().mockResolvedValue(undefined)
|
|
329
|
+
const onReopen = jest.fn().mockResolvedValue(undefined)
|
|
330
|
+
render(
|
|
331
|
+
<ChangesRail
|
|
332
|
+
{...base}
|
|
333
|
+
threads={[thread('t1', 'second', 'Why?'), thread('t2', 'world', 'Done', { resolved: true, resolution: { action: 'resolved', by: bo, at: null } })]}
|
|
334
|
+
canAccept={false}
|
|
335
|
+
onReply={onReply}
|
|
336
|
+
onResolve={onResolve}
|
|
337
|
+
onReopen={onReopen}
|
|
338
|
+
/>,
|
|
339
|
+
)
|
|
340
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reply' }))
|
|
341
|
+
fireEvent.change(screen.getByLabelText('Reply'), { target: { value: 'Because.' } })
|
|
342
|
+
const replies = screen.getAllByRole('button', { name: 'Reply' })
|
|
343
|
+
fireEvent.click(replies[replies.length - 1])
|
|
344
|
+
await waitFor(() => expect(onReply).toHaveBeenCalledWith('t1', 'Because.', []))
|
|
345
|
+
fireEvent.click(screen.getByRole('button', { name: 'Resolve' }))
|
|
346
|
+
await waitFor(() => expect(onResolve).toHaveBeenCalledWith('t1'))
|
|
347
|
+
expect(screen.getByText('Resolved (1)')).toBeInTheDocument()
|
|
348
|
+
expect(screen.getByText('Resolved by bo@x.io')).toBeInTheDocument()
|
|
349
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reopen' }))
|
|
350
|
+
await waitFor(() => expect(onReopen).toHaveBeenCalledWith('t2'))
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('lists orphaned items with their quote instead of dropping them', () => {
|
|
354
|
+
render(<ChangesRail {...base} suggestions={[sugg('o', 'Hello', 'Hi', bo, { anchorState: 'orphaned' })]} threads={[]} canAccept />)
|
|
355
|
+
expect(screen.getByText(/Orphaned \(1\)/)).toBeInTheDocument()
|
|
356
|
+
expect(screen.getByText(/replace “Hello” with “Hi”/)).toBeInTheDocument()
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('comments on the selection with an @mention picked from the roster, and says nobody is emailed', async () => {
|
|
360
|
+
const onComment = jest.fn().mockResolvedValue(undefined)
|
|
361
|
+
const selection = { from: 6, to: 11, quote: 'world', prefix: 'Hello ', suffix: '. A' }
|
|
362
|
+
render(
|
|
363
|
+
<ChangesRail {...base} canAccept={false} onComment={onComment} selection={selection} mentionable={[{ sub: 'rita', label: 'rita@x.io' }, bo]} />,
|
|
364
|
+
)
|
|
365
|
+
expect(screen.getByText(/nobody is emailed/)).toBeInTheDocument()
|
|
366
|
+
expect(within(screen.getByTestId('new-comment')).getByText('“world”')).toBeInTheDocument()
|
|
367
|
+
const box = screen.getByLabelText('New comment on Blog')
|
|
368
|
+
fireEvent.change(box, { target: { value: 'Ask @ri', selectionStart: 7 } })
|
|
369
|
+
const option = await screen.findByRole('option', { name: 'rita@x.io' })
|
|
370
|
+
fireEvent.mouseDown(option)
|
|
371
|
+
expect((box as HTMLTextAreaElement).value).toBe('Ask @rita@x.io ')
|
|
372
|
+
fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
|
|
373
|
+
await waitFor(() => expect(onComment).toHaveBeenCalledWith('Ask @rita@x.io', ['rita'], selection))
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('keeps the typed comment when the write fails', async () => {
|
|
377
|
+
const onComment = jest.fn().mockRejectedValue(new Error('Could not add the comment: no.'))
|
|
378
|
+
render(<ChangesRail {...base} canAccept={false} onComment={onComment} />)
|
|
379
|
+
const box = screen.getByLabelText('New comment on Blog')
|
|
380
|
+
fireEvent.change(box, { target: { value: 'Keep me' } })
|
|
381
|
+
fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
|
|
382
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add the comment')
|
|
383
|
+
expect((box as HTMLTextAreaElement).value).toBe('Keep me')
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('says why mentions are limited when the roster cannot be read', () => {
|
|
387
|
+
render(<ChangesRail {...base} canAccept={false} onComment={jest.fn()} mentionNotice="Your account cannot read the colleague list." />)
|
|
388
|
+
expect(screen.getByText('Your account cannot read the colleague list.')).toBeInTheDocument()
|
|
389
|
+
})
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
describe('mention helpers', () => {
|
|
393
|
+
it('finds the @query at the caret', () => {
|
|
394
|
+
expect(mentionQuery('hi @ri', 6)).toEqual({ start: 3, query: 'ri' })
|
|
395
|
+
expect(mentionQuery('mail a@b', 8)).toBeNull()
|
|
396
|
+
})
|
|
397
|
+
it('keeps only picked mentions still in the body', () => {
|
|
398
|
+
expect(mentionsIn('ping @rita@x.io', [{ sub: 'rita', label: 'rita@x.io' }, { sub: 'bo', label: 'bo@x.io' }])).toEqual(['rita'])
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
/* -------------------------------------------------------------------------- */
|
|
403
|
+
/* useTrackChanges: stale reads, writes, roster */
|
|
404
|
+
/* -------------------------------------------------------------------------- */
|
|
405
|
+
|
|
406
|
+
function page<T>(results: T[], version = 3) {
|
|
407
|
+
return { offset_unit: 'utf16' as const, version, results, next_cursor: null }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
describe('useTrackChanges (rail half)', () => {
|
|
411
|
+
it('says so when a read describes another version, instead of rendering an empty list', async () => {
|
|
412
|
+
const client: TrackChangesClient = {
|
|
413
|
+
listSuggestions: jest.fn().mockResolvedValue(page([{ id: 'x', anchor: { from: 0, to: 5, quote: 'Hello' }, replacement: 'Hi', author: { sub: 'bo' } }], 4)),
|
|
414
|
+
}
|
|
415
|
+
const { result } = renderHook(() => useTrackChanges({ client, field: 'blog', value: TEXT, version: 3 }))
|
|
416
|
+
await waitFor(() => expect(result.current.stale).toBe(true))
|
|
417
|
+
expect(result.current.suggestions).toEqual([])
|
|
418
|
+
expect(result.current.notices[0]).toMatch(/Someone changed this record since you opened it/)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('accepts at the version it is given, then re-reads', async () => {
|
|
422
|
+
const accept = jest.fn().mockResolvedValue({ id: 'd', version: 4, data: { blog: 'x' } })
|
|
423
|
+
const listSuggestions = jest.fn().mockResolvedValue(page([]))
|
|
424
|
+
const client: TrackChangesClient = { accept, listSuggestions }
|
|
425
|
+
const { result } = renderHook(() => useTrackChanges({ client, field: 'blog', value: TEXT, version: 3 }))
|
|
426
|
+
await waitFor(() => expect(listSuggestions).toHaveBeenCalledTimes(1))
|
|
427
|
+
let out: unknown
|
|
428
|
+
await act(async () => {
|
|
429
|
+
out = await result.current.actions.accept!(['s1'], 3)
|
|
430
|
+
})
|
|
431
|
+
expect(accept).toHaveBeenCalledWith({ ids: ['s1'], expectedVersion: 3 })
|
|
432
|
+
expect(out).toEqual({ id: 'd', version: 4, data: { blog: 'x' } })
|
|
433
|
+
await waitFor(() => expect(listSuggestions).toHaveBeenCalledTimes(2))
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('turns a refusal into the server reason in words', async () => {
|
|
437
|
+
const client: TrackChangesClient = {
|
|
438
|
+
accept: jest.fn().mockRejectedValue(new TrackChangesHttpError(409, { detail: 'x', reason: 'conflicted' })),
|
|
439
|
+
}
|
|
440
|
+
const { result } = renderHook(() => useTrackChanges({ client, field: 'blog', value: TEXT, version: 3 }))
|
|
441
|
+
await expect(result.current.actions.accept!(['s1'], 3)).rejects.toThrow(/the text under this suggestion changed/)
|
|
442
|
+
await expect(
|
|
443
|
+
renderHook(() =>
|
|
444
|
+
useTrackChanges({ client: { accept: jest.fn().mockRejectedValue(new TrackChangesHttpError(412, {})) }, field: 'blog', value: TEXT, version: 3 }),
|
|
445
|
+
).result.current.actions.accept!(['s1'], 3),
|
|
446
|
+
).rejects.toThrow(/someone changed this record/)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('offers the roster plus everyone on the trail, deduped by sub, and never a machine', async () => {
|
|
450
|
+
const roster = jest
|
|
451
|
+
.fn()
|
|
452
|
+
.mockResolvedValueOnce({ results: [{ sub: 'rita', label: 'rita@x.io' }, { sub: 'bo', label: 'bo@x.io' }], next_cursor: 'p2' })
|
|
453
|
+
.mockResolvedValueOnce({ results: [{ sub: 'rita', label: 'rita@x.io' }], next_cursor: null })
|
|
454
|
+
const client: TrackChangesClient = {
|
|
455
|
+
roster,
|
|
456
|
+
listSuggestions: jest.fn().mockResolvedValue(
|
|
457
|
+
page([
|
|
458
|
+
{ id: 'a', anchor: { from: 0, to: 5, quote: 'Hello' }, replacement: 'Hi', author: { sub: 'zed', label: 'zed@x.io', kind: 'person' } },
|
|
459
|
+
{ id: 'b', anchor: { from: 6, to: 11, quote: 'world' }, replacement: 'w', author: { sub: 'svc:n8n', label: 'bot', kind: 'machine' } },
|
|
460
|
+
]),
|
|
461
|
+
),
|
|
462
|
+
}
|
|
463
|
+
const { result } = renderHook(() => useTrackChanges({ client, field: 'blog', value: TEXT, version: 3 }))
|
|
464
|
+
await waitFor(() => expect(result.current.mentionable.map((m) => m.sub)).toEqual(['bo', 'rita', 'zed']))
|
|
465
|
+
expect(roster).toHaveBeenCalledTimes(2)
|
|
466
|
+
expect(result.current.rosterNotice).toBeNull()
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('says why when the roster is refused (a viewer-role token gets central 403)', async () => {
|
|
470
|
+
const client: TrackChangesClient = { roster: jest.fn().mockRejectedValue(new TrackChangesHttpError(403, { detail: 'no' })) }
|
|
471
|
+
const { result } = renderHook(() => useTrackChanges({ client, field: 'blog', value: TEXT, version: 3 }))
|
|
472
|
+
await waitFor(() => expect(result.current.rosterNotice).toMatch(/cannot read the colleague list/))
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('asks for every thread, resolved included, so the rail can list them', async () => {
|
|
476
|
+
const listComments = jest.fn().mockResolvedValue(page([]))
|
|
477
|
+
renderHook(() => useTrackChanges({ client: { listComments }, field: 'blog', value: TEXT, version: 3 }))
|
|
478
|
+
await waitFor(() => expect(listComments).toHaveBeenCalledWith({ field: 'blog', status: 'all' }))
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
/* -------------------------------------------------------------------------- */
|
|
483
|
+
/* The HTTP client */
|
|
484
|
+
/* -------------------------------------------------------------------------- */
|
|
485
|
+
|
|
486
|
+
describe('createHttpTrackChangesClient', () => {
|
|
487
|
+
function fake(status = 200, body: unknown = {}) {
|
|
488
|
+
return jest.fn().mockResolvedValue({ ok: status < 400, status, json: async () => body })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
it('accepts with ONE precondition spelling, in the body', async () => {
|
|
492
|
+
const f = fake(200, { version: 5 })
|
|
493
|
+
const c = createHttpTrackChangesClient('d1', { headers: () => ({ authorization: 'Bearer t' }), url: (p) => p, fetch: f })
|
|
494
|
+
await c.accept!({ ids: ['s1'], expectedVersion: 4 })
|
|
495
|
+
const [url, init] = f.mock.calls[0]
|
|
496
|
+
expect(url).toBe('/api/v1/entities/d1/suggestions/accept')
|
|
497
|
+
expect(JSON.parse(init.body)).toEqual({ ids: ['s1'], expected_version: 4 })
|
|
498
|
+
expect(init.headers).not.toHaveProperty('If-Match')
|
|
499
|
+
expect(init.headers.authorization).toBe('Bearer t')
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('reads with query strings and a trailing slash by default', async () => {
|
|
503
|
+
const f = fake(200, {})
|
|
504
|
+
const c = createHttpTrackChangesClient('d1', { headers: async () => ({}), fetch: f })
|
|
505
|
+
await c.listComments!({ field: 'blog', status: 'all' })
|
|
506
|
+
await c.roster!({})
|
|
507
|
+
expect(f.mock.calls[0][0]).toBe('/api/v1/entities/d1/comments/?field=blog&status=all')
|
|
508
|
+
expect(f.mock.calls[1][0]).toBe('/api/v1/org/roster/')
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
it('throws the status and body on a refusal', async () => {
|
|
512
|
+
const c = createHttpTrackChangesClient('d1', { headers: () => ({}), fetch: fake(409, { detail: 'nope', reason: 'conflicted' }) })
|
|
513
|
+
await expect(c.reject!({ ids: ['x'] })).rejects.toMatchObject({ status: 409, body: { reason: 'conflicted' } })
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
/* -------------------------------------------------------------------------- */
|
|
518
|
+
/* TrackedField */
|
|
519
|
+
/* -------------------------------------------------------------------------- */
|
|
520
|
+
|
|
521
|
+
describe('TrackedField', () => {
|
|
522
|
+
const wire = {
|
|
523
|
+
id: 's1',
|
|
524
|
+
set_id: 'set',
|
|
525
|
+
field: 'blog',
|
|
526
|
+
status: 'open',
|
|
527
|
+
anchor: { from: 13, to: 14, quote: 'A' },
|
|
528
|
+
position: { state: 'mapped', from: 13, to: 14, version: 3 },
|
|
529
|
+
replacement: 'One',
|
|
530
|
+
author: { sub: 'vee', label: 'vee@x.io', kind: 'person' },
|
|
531
|
+
}
|
|
532
|
+
function client(extra: Partial<TrackChangesClient> = {}): TrackChangesClient {
|
|
533
|
+
return {
|
|
534
|
+
listSuggestions: jest.fn().mockResolvedValue(page([wire])),
|
|
535
|
+
listComments: jest.fn().mockResolvedValue(page([])),
|
|
536
|
+
accept: jest.fn().mockResolvedValue({ version: 4 }),
|
|
537
|
+
comment: jest.fn().mockResolvedValue({}),
|
|
538
|
+
...extra,
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
it('shows Final with the open suggestions applied, and Original as stored', async () => {
|
|
543
|
+
render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="read" />)
|
|
544
|
+
await screen.findByRole('button', { name: 'Accept' })
|
|
545
|
+
fireEvent.click(screen.getByRole('button', { name: 'Final' }))
|
|
546
|
+
expect(screen.getByTestId('tracked-read-view')).toHaveTextContent('Hello world. One second sentence. The end.')
|
|
547
|
+
expect(screen.getByText(/as it would read with 1 open suggestion accepted/i)).toBeInTheDocument()
|
|
548
|
+
fireEvent.click(screen.getByRole('button', { name: 'Original' }))
|
|
549
|
+
expect(screen.getByTestId('tracked-read-view')).toHaveTextContent(TEXT)
|
|
550
|
+
expect(screen.getByTestId('tracked-read-view').querySelector('del')).toBeNull()
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('hands the accept to the host so it asserts its own live version', async () => {
|
|
554
|
+
const c = client()
|
|
555
|
+
const runAccept = jest.fn(async (doAccept: (v: number) => Promise<unknown>) => doAccept(9))
|
|
556
|
+
render(<TrackedField client={c} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="read" runAccept={runAccept} />)
|
|
557
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Accept' }))
|
|
558
|
+
await waitFor(() => expect(c.accept).toHaveBeenCalledWith({ ids: ['s1'], expectedVersion: 9 }))
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('gives a view-only reviewer no accept, but a Suggest surface and comments', async () => {
|
|
562
|
+
render(<TrackedField client={client()} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="vee" canEdit={false} defaultSurface="read" />)
|
|
563
|
+
await screen.findByTestId('rail-suggestion')
|
|
564
|
+
expect(screen.queryByRole('button', { name: 'Accept' })).toBeNull()
|
|
565
|
+
expect(screen.getByRole('button', { name: 'Suggest' })).toBeInTheDocument()
|
|
566
|
+
expect(screen.getByLabelText('New comment on Blog')).toBeInTheDocument()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('a selection made in the source editor becomes the comment anchor', async () => {
|
|
570
|
+
const rect = { x: 0, y: 0, top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON() {} }
|
|
571
|
+
Range.prototype.getBoundingClientRect = () => rect as DOMRect
|
|
572
|
+
Range.prototype.getClientRects = () => ({ length: 0, item: () => null, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList
|
|
573
|
+
const c = client()
|
|
574
|
+
render(<TrackedField client={c} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="source" />)
|
|
575
|
+
await screen.findByTestId('rail-suggestion')
|
|
576
|
+
const { EditorView } = jest.requireActual('@codemirror/view')
|
|
577
|
+
const view = EditorView.findFromDOM(document.querySelector('.cm-editor') as HTMLElement)!
|
|
578
|
+
act(() => {
|
|
579
|
+
view.dispatch({ selection: { anchor: 6, head: 11 } })
|
|
580
|
+
})
|
|
581
|
+
expect(within(screen.getByTestId('new-comment')).getByText('“world”')).toBeInTheDocument()
|
|
582
|
+
fireEvent.change(screen.getByLabelText('New comment on Blog'), { target: { value: 'Hm' } })
|
|
583
|
+
fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
|
|
584
|
+
await waitFor(() => expect(c.comment).toHaveBeenCalledWith({ body: 'Hm', field: 'blog', from: 6, to: 11, base_version: 3 }))
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('comments on a span at the loaded version', async () => {
|
|
588
|
+
const c = client()
|
|
589
|
+
render(<TrackedField client={c} field="blog" label="Blog" language="plain" value={TEXT} version={3} currentActorSub="ada" defaultSurface="read" />)
|
|
590
|
+
await screen.findByTestId('rail-suggestion')
|
|
591
|
+
const run = screen.getByTestId('tracked-read-view').querySelector('[data-src-from="0"]')!.firstChild!
|
|
592
|
+
const range = document.createRange()
|
|
593
|
+
range.setStart(run, 0)
|
|
594
|
+
range.setEnd(run, 5)
|
|
595
|
+
window.getSelection()!.removeAllRanges()
|
|
596
|
+
window.getSelection()!.addRange(range)
|
|
597
|
+
fireEvent.mouseUp(screen.getByTestId('tracked-read-view'))
|
|
598
|
+
fireEvent.change(screen.getByLabelText('New comment on Blog'), { target: { value: 'Warmer?' } })
|
|
599
|
+
fireEvent.click(screen.getByRole('button', { name: 'Comment' }))
|
|
600
|
+
await waitFor(() =>
|
|
601
|
+
expect(c.comment).toHaveBeenCalledWith({ body: 'Warmer?', field: 'blog', from: 0, to: 5, base_version: 3 }),
|
|
602
|
+
)
|
|
603
|
+
})
|
|
604
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The FINAL and ORIGINAL views (bd startsim-whwxd.3; design startsim-xyn25
|
|
3
|
+
* section d).
|
|
4
|
+
*
|
|
5
|
+
* Read literally, the design's two views are the same text: the stored string
|
|
6
|
+
* never has a suggestion applied, so "the current text" and "the base with no
|
|
7
|
+
* suggestion applied" are one string. They follow Word's meaning instead:
|
|
8
|
+
*
|
|
9
|
+
* ORIGINAL the stored text, with no marks.
|
|
10
|
+
* FINAL the stored text with every OPEN, PLACED suggestion applied,
|
|
11
|
+
* with no marks: what the record would read if all were accepted.
|
|
12
|
+
*
|
|
13
|
+
* Two suggestions that overlap cannot both be applied, so the later one (in
|
|
14
|
+
* document order) is skipped and NAMED, never merged by guesswork. The server
|
|
15
|
+
* is the one that applies a real accept; this is a preview only.
|
|
16
|
+
*/
|
|
17
|
+
import type { Suggestion } from './types';
|
|
18
|
+
|
|
19
|
+
export interface FinalText {
|
|
20
|
+
text: string;
|
|
21
|
+
/** Applied, in document order. */
|
|
22
|
+
applied: string[];
|
|
23
|
+
/** Open and placed, but overlapping one already applied. */
|
|
24
|
+
skipped: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function placedOpen(suggestions: Suggestion[]): Suggestion[] {
|
|
28
|
+
return suggestions
|
|
29
|
+
.filter((s) => s.status === 'open' && s.anchorState !== 'orphaned')
|
|
30
|
+
.slice()
|
|
31
|
+
.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function applyOpenSuggestions(text: string, suggestions: Suggestion[]): FinalText {
|
|
35
|
+
const applied: string[] = [];
|
|
36
|
+
const skipped: string[] = [];
|
|
37
|
+
let out = '';
|
|
38
|
+
let last = 0;
|
|
39
|
+
let lastInsertAt = -1;
|
|
40
|
+
for (const s of placedOpen(suggestions)) {
|
|
41
|
+
const overlaps = s.from < last || (s.from === s.to && s.from === lastInsertAt);
|
|
42
|
+
// The quote must still be what the text holds there; normalize already
|
|
43
|
+
// orphans a mismatch, so this is the belt to its braces.
|
|
44
|
+
if (overlaps || s.to > text.length || text.slice(s.from, s.to) !== s.quote) {
|
|
45
|
+
skipped.push(s.id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
out += text.slice(last, s.from) + s.replacement;
|
|
49
|
+
last = s.to;
|
|
50
|
+
lastInsertAt = s.from === s.to ? s.from : -1;
|
|
51
|
+
applied.push(s.id);
|
|
52
|
+
}
|
|
53
|
+
return { text: out + text.slice(last), applied, skipped };
|
|
54
|
+
}
|