@pilotiq/tiptap 3.10.4 → 3.10.6
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 +745 -0
- package/boost/guidelines.md +268 -0
- package/boost/skills/pilotiq-tiptap-blocks/SKILL.md +48 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/custom-blocks.md +90 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/slash-menu-and-mentions.md +101 -0
- package/boost/skills/pilotiq-tiptap-blocks/rules/toolbar-and-extensibility.md +161 -0
- package/dist/react/CollabTextRenderer.d.ts.map +1 -1
- package/dist/react/CollabTextRenderer.js +4 -4
- package/dist/react/CollabTextRenderer.js.map +1 -1
- package/dist/react/MarkdownEditor.d.ts.map +1 -1
- package/dist/react/MarkdownEditor.js +4 -5
- package/dist/react/MarkdownEditor.js.map +1 -1
- package/dist/react/TiptapEditor.d.ts.map +1 -1
- package/dist/react/TiptapEditor.js +8 -7
- package/dist/react/TiptapEditor.js.map +1 -1
- package/package.json +6 -3
- package/dist/collabShapes.d.ts +0 -22
- package/dist/collabShapes.d.ts.map +0 -1
- package/dist/collabShapes.js +0 -2
- package/dist/collabShapes.js.map +0 -1
- package/src/Block.ts +0 -75
- package/src/MentionProvider.ts +0 -153
- package/src/PlainTextEditor.dom.test.ts +0 -111
- package/src/PlainTextEditor.test.ts +0 -158
- package/src/PlainTextEditor.ts +0 -229
- package/src/RichTextField.test.ts +0 -447
- package/src/RichTextField.ts +0 -508
- package/src/collabShapes.ts +0 -22
- package/src/extensions/AiInlineDiffExtension.ts +0 -286
- package/src/extensions/AiSuggestionExtension.test.ts +0 -141
- package/src/extensions/AiSuggestionExtension.ts +0 -522
- package/src/extensions/BlockNodeExtension.ts +0 -134
- package/src/extensions/DragHandleExtension.ts +0 -184
- package/src/extensions/GridExtension.test.ts +0 -31
- package/src/extensions/GridExtension.ts +0 -138
- package/src/extensions/MentionExtension.ts +0 -248
- package/src/extensions/MergeTagExtension.ts +0 -75
- package/src/extensions/SlashCommandExtension.test.ts +0 -147
- package/src/extensions/SlashCommandExtension.ts +0 -332
- package/src/extensions/TextSizeMarks.ts +0 -73
- package/src/index.ts +0 -62
- package/src/markdownExtension.ts +0 -19
- package/src/markdownStorage.ts +0 -49
- package/src/plugin.test.ts +0 -19
- package/src/plugin.ts +0 -26
- package/src/react/AiSuggestionBanner.tsx +0 -185
- package/src/react/BlockNodeView.tsx +0 -99
- package/src/react/BlockSidePanel.dom.test.tsx +0 -38
- package/src/react/BlockSidePanel.test.ts +0 -412
- package/src/react/BlockSidePanel.tsx +0 -451
- package/src/react/CollabTextRenderer.tsx +0 -230
- package/src/react/FloatingToolbar.tsx +0 -304
- package/src/react/MarkdownEditor.tsx +0 -606
- package/src/react/MentionMenu.tsx +0 -120
- package/src/react/Palette.tsx +0 -86
- package/src/react/SlashMenu.tsx +0 -129
- package/src/react/TableFloatingToolbar.tsx +0 -154
- package/src/react/TiptapEditor.dom.test.tsx +0 -112
- package/src/react/TiptapEditor.tsx +0 -776
- package/src/react/Toolbar.tsx +0 -438
- package/src/react/toolbarButtons.tsx +0 -579
- package/src/react/useAiInlineDiff.ts +0 -342
- package/src/react/useAiSuggestionBridge.ts +0 -223
- package/src/register.test.ts +0 -14
- package/src/register.ts +0 -42
- package/src/render.test.ts +0 -745
- package/src/render.ts +0 -480
- package/src/surgicalOps.ts +0 -205
- package/src/test/setup.ts +0 -64
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline-diff visualization for whole-field AI suggestions.
|
|
3
|
-
*
|
|
4
|
-
* Sibling to `AiSuggestionExtension`, which handles producer-supplied
|
|
5
|
-
* range suggestions (surgical edits with `meta.editorRange`) via the
|
|
6
|
-
* inline chip widget. This extension handles the *whole-field* case:
|
|
7
|
-
* the AI proposes a new document and the user reviews the structural
|
|
8
|
-
* delta (added paragraphs, deleted text, mark changes, etc.) before
|
|
9
|
-
* accepting or rejecting via the host-mounted `<AiSuggestionBanner>`.
|
|
10
|
-
*
|
|
11
|
-
* Architecture:
|
|
12
|
-
* 1. `startAiInlineDiff(id, newDoc)` captures the current doc as the
|
|
13
|
-
* baseline, replaces the doc body with `newDoc`'s content (so the
|
|
14
|
-
* editor surface IS the proposed state), and initializes a
|
|
15
|
-
* `prosemirror-changeset` tracking the original-to-current
|
|
16
|
-
* transition.
|
|
17
|
-
* 2. The plugin appendTransaction hook keeps the changeset in sync
|
|
18
|
-
* with any further transactions while the diff is pending — e.g.
|
|
19
|
-
* y-prosemirror remote edits arriving during review. (Rare on
|
|
20
|
-
* whole-field flows but free.)
|
|
21
|
-
* 3. A decorations spec walks `ChangeSet.changes` and emits:
|
|
22
|
-
* - inline green-background decorations on inserted ranges
|
|
23
|
-
* - widget decorations rendering the *deleted* text struck
|
|
24
|
-
* through next to the insert point (the deleted content
|
|
25
|
-
* isn't in the current doc, so a widget is the only way to
|
|
26
|
-
* surface it)
|
|
27
|
-
* 4. `acceptAiInlineDiff()` clears the plugin state — the current
|
|
28
|
-
* doc is the accepted state.
|
|
29
|
-
* 5. `rejectAiInlineDiff()` replaces the doc back to the baseline
|
|
30
|
-
* via a single transaction and clears state.
|
|
31
|
-
*
|
|
32
|
-
* For Tiptap Pro parity. See `[[project_pilotiq_text_field_tiptap_rules]]`.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { Extension } from '@tiptap/core'
|
|
36
|
-
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
37
|
-
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|
38
|
-
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
39
|
-
import type { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model'
|
|
40
|
-
import { ChangeSet } from 'prosemirror-changeset'
|
|
41
|
-
|
|
42
|
-
declare module '@tiptap/core' {
|
|
43
|
-
interface Commands<ReturnType> {
|
|
44
|
-
aiInlineDiff: {
|
|
45
|
-
/**
|
|
46
|
-
* Start the inline-diff review session. Snapshots the current
|
|
47
|
-
* doc as the baseline, replaces the doc with `newDocSlice`'s
|
|
48
|
-
* content, and shows the diff overlay.
|
|
49
|
-
*
|
|
50
|
-
* `id` is the host-side `PendingSuggestion.id` — used so the
|
|
51
|
-
* banner / approve handlers can correlate the editor state with
|
|
52
|
-
* the queue entry.
|
|
53
|
-
*/
|
|
54
|
-
startAiInlineDiff: (id: string, newDocSlice: Slice) => ReturnType
|
|
55
|
-
/**
|
|
56
|
-
* Start the inline-diff review session for a surgical edit.
|
|
57
|
-
* Snapshots the current doc as the baseline, then runs
|
|
58
|
-
* `applyFn(tr)` to mutate the transaction with a precise change
|
|
59
|
-
* (e.g. replace one block, insert before a position, set a mark
|
|
60
|
-
* on a range). The plugin folds the resulting steps into the
|
|
61
|
-
* changeset, so decorations land exactly on the modified ranges
|
|
62
|
-
* — no whole-doc replacement.
|
|
63
|
-
*
|
|
64
|
-
* Use this for `replace_block` / `insert_block_before` /
|
|
65
|
-
* `delete_block` / `update_block_mark` AI ops. Returns false (no
|
|
66
|
-
* dispatch) when `applyFn` produced no doc change.
|
|
67
|
-
*/
|
|
68
|
-
applySurgicalAiInlineDiff: (id: string, applyFn: (tr: Transaction) => void) => ReturnType
|
|
69
|
-
/** Clear diff state. Current doc IS the accepted state. */
|
|
70
|
-
acceptAiInlineDiff: () => ReturnType
|
|
71
|
-
/** Revert doc to the captured baseline and clear diff state. */
|
|
72
|
-
rejectAiInlineDiff: () => ReturnType
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
interface DiffState {
|
|
78
|
-
id: string
|
|
79
|
-
/** Original doc captured at `startAiInlineDiff` time — used for revert. */
|
|
80
|
-
baseline: ProseMirrorNode
|
|
81
|
-
/** ChangeSet accumulating diffs since baseline. */
|
|
82
|
-
changeset: ChangeSet
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const aiInlineDiffPluginKey = new PluginKey<DiffState | null>('pilotiqAiInlineDiff')
|
|
86
|
-
|
|
87
|
-
/** Read the active diff state, if any. Public for hosts that want to
|
|
88
|
-
* branch their banner UI on "diff active" vs "diff inactive". */
|
|
89
|
-
export function getAiInlineDiffState(state: EditorState): DiffState | null {
|
|
90
|
-
return aiInlineDiffPluginKey.getState(state) ?? null
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
interface StartMeta { type: 'start'; id: string; baseline: ProseMirrorNode }
|
|
94
|
-
interface ClearMeta { type: 'clear' }
|
|
95
|
-
type DiffMeta = StartMeta | ClearMeta
|
|
96
|
-
|
|
97
|
-
export interface AiInlineDiffExtensionOptions {
|
|
98
|
-
/**
|
|
99
|
-
* Class prefix for inline-diff decorations. Defaults to
|
|
100
|
-
* `'pilotiq-ai-diff'`, producing:
|
|
101
|
-
* - `pilotiq-ai-diff-inserted` (green-background span on new ranges)
|
|
102
|
-
* - `pilotiq-ai-diff-deleted` (widget DOM root for deleted text)
|
|
103
|
-
* - `pilotiq-ai-diff-deleted-text` (the strikethrough span inside)
|
|
104
|
-
*/
|
|
105
|
-
classPrefix?: string
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export const AiInlineDiffExtension = Extension.create<AiInlineDiffExtensionOptions>({
|
|
109
|
-
name: 'pilotiqAiInlineDiff',
|
|
110
|
-
|
|
111
|
-
addOptions() {
|
|
112
|
-
return { classPrefix: 'pilotiq-ai-diff' }
|
|
113
|
-
},
|
|
114
|
-
|
|
115
|
-
onCreate() {
|
|
116
|
-
// Mirror the chip CSS injection pattern. Idempotent via sentinel.
|
|
117
|
-
if (typeof document === 'undefined') return
|
|
118
|
-
const SENTINEL = 'data-pilotiq-ai-diff-styles'
|
|
119
|
-
if (document.head.querySelector(`style[${SENTINEL}]`)) return
|
|
120
|
-
const prefix = this.options.classPrefix
|
|
121
|
-
const style = document.createElement('style')
|
|
122
|
-
style.setAttribute(SENTINEL, '')
|
|
123
|
-
style.textContent = `
|
|
124
|
-
.${prefix}-inserted {
|
|
125
|
-
background-color: rgba(187, 247, 208, 0.55);
|
|
126
|
-
color: rgb(20, 83, 45);
|
|
127
|
-
text-decoration: none;
|
|
128
|
-
}
|
|
129
|
-
.${prefix}-deleted {
|
|
130
|
-
display: inline;
|
|
131
|
-
margin-right: 0.125em;
|
|
132
|
-
}
|
|
133
|
-
.${prefix}-deleted-text {
|
|
134
|
-
text-decoration: line-through;
|
|
135
|
-
text-decoration-color: rgba(220, 38, 38, 0.7);
|
|
136
|
-
background-color: rgba(254, 226, 226, 0.55);
|
|
137
|
-
color: rgb(153, 27, 27);
|
|
138
|
-
padding: 0 0.125em;
|
|
139
|
-
}
|
|
140
|
-
`
|
|
141
|
-
document.head.appendChild(style)
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
addCommands() {
|
|
145
|
-
return {
|
|
146
|
-
startAiInlineDiff: (id, newDocSlice) => ({ tr, state, dispatch }) => {
|
|
147
|
-
const baseline = state.doc
|
|
148
|
-
const docEnd = state.doc.content.size
|
|
149
|
-
// Replace the whole doc body with the proposed content. The
|
|
150
|
-
// schema enforces validity — if the slice doesn't fit, ProseMirror
|
|
151
|
-
// throws (callers should pre-validate via `editor.schema`).
|
|
152
|
-
tr.replaceRange(0, docEnd, newDocSlice)
|
|
153
|
-
const meta: StartMeta = { type: 'start', id, baseline }
|
|
154
|
-
tr.setMeta(aiInlineDiffPluginKey, meta)
|
|
155
|
-
if (dispatch) dispatch(tr)
|
|
156
|
-
return true
|
|
157
|
-
},
|
|
158
|
-
applySurgicalAiInlineDiff: (id, applyFn) => ({ tr, state, dispatch }) => {
|
|
159
|
-
const baseline = state.doc
|
|
160
|
-
applyFn(tr)
|
|
161
|
-
if (!tr.docChanged) return false
|
|
162
|
-
const meta: StartMeta = { type: 'start', id, baseline }
|
|
163
|
-
tr.setMeta(aiInlineDiffPluginKey, meta)
|
|
164
|
-
if (dispatch) dispatch(tr)
|
|
165
|
-
return true
|
|
166
|
-
},
|
|
167
|
-
acceptAiInlineDiff: () => ({ tr, dispatch }) => {
|
|
168
|
-
const meta: ClearMeta = { type: 'clear' }
|
|
169
|
-
tr.setMeta(aiInlineDiffPluginKey, meta)
|
|
170
|
-
if (dispatch) dispatch(tr)
|
|
171
|
-
return true
|
|
172
|
-
},
|
|
173
|
-
rejectAiInlineDiff: () => ({ tr, state, dispatch }) => {
|
|
174
|
-
const ds = aiInlineDiffPluginKey.getState(state)
|
|
175
|
-
if (!ds) return false
|
|
176
|
-
const docEnd = state.doc.content.size
|
|
177
|
-
// Replace whole body with the baseline's content via a slice that
|
|
178
|
-
// spans the baseline's open boundaries (always 0 for a top-level
|
|
179
|
-
// doc replace).
|
|
180
|
-
tr.replaceWith(0, docEnd, ds.baseline.content)
|
|
181
|
-
const meta: ClearMeta = { type: 'clear' }
|
|
182
|
-
tr.setMeta(aiInlineDiffPluginKey, meta)
|
|
183
|
-
if (dispatch) dispatch(tr)
|
|
184
|
-
return true
|
|
185
|
-
},
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
addProseMirrorPlugins() {
|
|
190
|
-
const ext = this
|
|
191
|
-
return [
|
|
192
|
-
new Plugin<DiffState | null>({
|
|
193
|
-
key: aiInlineDiffPluginKey,
|
|
194
|
-
state: {
|
|
195
|
-
init() { return null },
|
|
196
|
-
apply(tr, value) {
|
|
197
|
-
const meta = tr.getMeta(aiInlineDiffPluginKey) as DiffMeta | undefined
|
|
198
|
-
if (meta?.type === 'start') {
|
|
199
|
-
// Baseline captured BEFORE the replaceRange step in this
|
|
200
|
-
// same transaction. The changeset's `addSteps` consumes
|
|
201
|
-
// the transaction's step list to compute the diff between
|
|
202
|
-
// the baseline doc and the post-transaction doc.
|
|
203
|
-
const cs = ChangeSet.create(meta.baseline).addSteps(tr.doc, tr.mapping.maps, null)
|
|
204
|
-
return { id: meta.id, baseline: meta.baseline, changeset: cs }
|
|
205
|
-
}
|
|
206
|
-
if (meta?.type === 'clear') return null
|
|
207
|
-
if (!value) return value
|
|
208
|
-
// No explicit meta — a regular transaction landed while the
|
|
209
|
-
// diff was active. Fold its steps into the changeset so any
|
|
210
|
-
// further edits (e.g. y-prosemirror remote ops) are reflected.
|
|
211
|
-
if (tr.docChanged) {
|
|
212
|
-
const cs = value.changeset.addSteps(tr.doc, tr.mapping.maps, null)
|
|
213
|
-
return { ...value, changeset: cs }
|
|
214
|
-
}
|
|
215
|
-
return value
|
|
216
|
-
},
|
|
217
|
-
},
|
|
218
|
-
props: {
|
|
219
|
-
decorations(state) {
|
|
220
|
-
const ds = aiInlineDiffPluginKey.getState(state)
|
|
221
|
-
if (!ds) return DecorationSet.empty
|
|
222
|
-
return buildDiffDecorations(state, ds, ext.options.classPrefix ?? 'pilotiq-ai-diff')
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
}),
|
|
226
|
-
]
|
|
227
|
-
},
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
function buildDiffDecorations(
|
|
231
|
-
state: EditorState,
|
|
232
|
-
ds: DiffState,
|
|
233
|
-
prefix: string,
|
|
234
|
-
): DecorationSet {
|
|
235
|
-
const decos: Decoration[] = []
|
|
236
|
-
const docSize = state.doc.content.size
|
|
237
|
-
|
|
238
|
-
for (const change of ds.changeset.changes) {
|
|
239
|
-
// `fromB..toB` is the range in the CURRENT doc that holds the
|
|
240
|
-
// inserted content. `fromA..toA` is the range in the BASELINE doc
|
|
241
|
-
// that was removed. `inserted` / `deleted` are Span[] arrays whose
|
|
242
|
-
// `length` sums to (toB - fromB) / (toA - fromA) respectively.
|
|
243
|
-
const fromB = Math.max(0, Math.min(change.fromB, docSize))
|
|
244
|
-
const toB = Math.max(fromB, Math.min(change.toB, docSize))
|
|
245
|
-
|
|
246
|
-
if (toB > fromB) {
|
|
247
|
-
decos.push(
|
|
248
|
-
Decoration.inline(fromB, toB, {
|
|
249
|
-
class: `${prefix}-inserted`,
|
|
250
|
-
'data-pilotiq-ai-diff-id': ds.id,
|
|
251
|
-
}),
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Deleted text — pull from the baseline using the `fromA..toA` range.
|
|
256
|
-
// Render via a widget at the change's insert-point (or end of insert)
|
|
257
|
-
// so the deleted text appears immediately before / after the new run.
|
|
258
|
-
// Empty deletions (pure inserts) skip the widget.
|
|
259
|
-
if (change.toA > change.fromA) {
|
|
260
|
-
const deletedText = ds.baseline.textBetween(change.fromA, change.toA, '\n', ' ')
|
|
261
|
-
if (deletedText.length > 0) {
|
|
262
|
-
decos.push(
|
|
263
|
-
Decoration.widget(fromB, () => buildDeletedWidget(deletedText, prefix, ds.id), {
|
|
264
|
-
side: -1,
|
|
265
|
-
ignoreSelection: true,
|
|
266
|
-
key: `pilotiq-ai-diff:deleted:${change.fromA}:${change.toA}`,
|
|
267
|
-
}),
|
|
268
|
-
)
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return DecorationSet.create(state.doc, decos)
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function buildDeletedWidget(text: string, prefix: string, id: string): HTMLElement {
|
|
277
|
-
const root = document.createElement('span')
|
|
278
|
-
root.className = `${prefix}-deleted`
|
|
279
|
-
root.setAttribute('data-pilotiq-ai-diff-id', id)
|
|
280
|
-
root.contentEditable = 'false'
|
|
281
|
-
const inner = document.createElement('span')
|
|
282
|
-
inner.className = `${prefix}-deleted-text`
|
|
283
|
-
inner.textContent = text
|
|
284
|
-
root.appendChild(inner)
|
|
285
|
-
return root
|
|
286
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
2
|
-
import assert from 'node:assert/strict'
|
|
3
|
-
import {
|
|
4
|
-
upsertSuggestion,
|
|
5
|
-
upsertSuggestions,
|
|
6
|
-
removeSuggestion,
|
|
7
|
-
remapSuggestions,
|
|
8
|
-
sortForApproveAll,
|
|
9
|
-
clampPos,
|
|
10
|
-
type AiSuggestion,
|
|
11
|
-
} from './AiSuggestionExtension.js'
|
|
12
|
-
|
|
13
|
-
const make = (id: string, from: number, to: number, replacement = '…'): AiSuggestion => ({
|
|
14
|
-
id, from, to, replacement,
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
describe('upsertSuggestion', () => {
|
|
18
|
-
it('appends a new suggestion when id is unseen', () => {
|
|
19
|
-
const list = [make('a', 0, 4)]
|
|
20
|
-
const out = upsertSuggestion(list, make('b', 8, 12))
|
|
21
|
-
assert.equal(out.length, 2)
|
|
22
|
-
assert.equal(out[1]!.id, 'b')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('replaces in place when id already exists', () => {
|
|
26
|
-
const list = [make('a', 0, 4, 'old'), make('b', 8, 12, 'old')]
|
|
27
|
-
const out = upsertSuggestion(list, make('a', 0, 4, 'new'))
|
|
28
|
-
assert.equal(out.length, 2)
|
|
29
|
-
assert.equal(out[0]!.replacement, 'new')
|
|
30
|
-
assert.equal(out[1]!.id, 'b')
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('does not mutate the input array', () => {
|
|
34
|
-
const list = [make('a', 0, 4)]
|
|
35
|
-
upsertSuggestion(list, make('b', 8, 12))
|
|
36
|
-
assert.equal(list.length, 1)
|
|
37
|
-
})
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
describe('upsertSuggestions', () => {
|
|
41
|
-
it('folds multiple inserts and replacements', () => {
|
|
42
|
-
const list = [make('a', 0, 4, 'old')]
|
|
43
|
-
const out = upsertSuggestions(list, [
|
|
44
|
-
make('a', 0, 4, 'new'),
|
|
45
|
-
make('b', 8, 12),
|
|
46
|
-
make('c', 16, 20),
|
|
47
|
-
])
|
|
48
|
-
assert.deepEqual(out.map(s => s.id), ['a', 'b', 'c'])
|
|
49
|
-
assert.equal(out[0]!.replacement, 'new')
|
|
50
|
-
})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('removeSuggestion', () => {
|
|
54
|
-
it('drops only the matching id', () => {
|
|
55
|
-
const list = [make('a', 0, 4), make('b', 8, 12)]
|
|
56
|
-
const out = removeSuggestion(list, 'a')
|
|
57
|
-
assert.deepEqual(out.map(s => s.id), ['b'])
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('returns the same shape when id is unseen', () => {
|
|
61
|
-
const list = [make('a', 0, 4)]
|
|
62
|
-
const out = removeSuggestion(list, 'unseen')
|
|
63
|
-
assert.deepEqual(out, list)
|
|
64
|
-
})
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
describe('remapSuggestions', () => {
|
|
68
|
-
it('shifts ranges through a forward-shift mapping', () => {
|
|
69
|
-
const list = [make('a', 10, 14)]
|
|
70
|
-
const out = remapSuggestions(list, (pos) => pos + 5)
|
|
71
|
-
assert.equal(out[0]!.from, 15)
|
|
72
|
-
assert.equal(out[0]!.to, 19)
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
it('drops ranges that collapsed past each other', () => {
|
|
76
|
-
const list = [make('a', 10, 14), make('b', 20, 24)]
|
|
77
|
-
// Map collapses everything to position 0 — `to (-1 in mapping bias) < from`.
|
|
78
|
-
const out = remapSuggestions(list, (pos, side) => (side === -1 ? pos : 0))
|
|
79
|
-
assert.equal(out.length, 0)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('keeps a pure-insertion range (`from === to`) when it survives the mapping', () => {
|
|
83
|
-
const list = [{ ...make('a', 5, 5), replacement: 'inserted' }]
|
|
84
|
-
const out = remapSuggestions(list, (pos) => pos + 3)
|
|
85
|
-
assert.equal(out.length, 1)
|
|
86
|
-
assert.equal(out[0]!.from, 8)
|
|
87
|
-
assert.equal(out[0]!.to, 8)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('biases sides — `from` left, `to` right — to keep insertions stable under collapsed text', () => {
|
|
91
|
-
const list = [make('a', 5, 10)]
|
|
92
|
-
// A mapping that collapses at exactly `pos = 5` — left bias keeps `from`
|
|
93
|
-
// anchored at 5; right bias for `to = 10` shifts it to 10. Range survives.
|
|
94
|
-
const out = remapSuggestions(list, (pos, side) => {
|
|
95
|
-
if (pos === 5 && side === -1) return 5
|
|
96
|
-
if (pos === 5 && side === 1) return 7
|
|
97
|
-
if (pos === 10 && side === -1) return 7
|
|
98
|
-
if (pos === 10 && side === 1) return 10
|
|
99
|
-
return pos
|
|
100
|
-
})
|
|
101
|
-
assert.equal(out.length, 1)
|
|
102
|
-
assert.equal(out[0]!.from, 5)
|
|
103
|
-
assert.equal(out[0]!.to, 10)
|
|
104
|
-
})
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
describe('sortForApproveAll', () => {
|
|
108
|
-
it('orders highest-`from` first so earlier positions are stable across replacements', () => {
|
|
109
|
-
const list = [make('a', 0, 4), make('c', 20, 24), make('b', 10, 14)]
|
|
110
|
-
const out = sortForApproveAll(list)
|
|
111
|
-
assert.deepEqual(out.map(s => s.id), ['c', 'b', 'a'])
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('does not mutate the input', () => {
|
|
115
|
-
const list = [make('a', 0, 4), make('b', 10, 14)]
|
|
116
|
-
sortForApproveAll(list)
|
|
117
|
-
assert.deepEqual(list.map(s => s.id), ['a', 'b'])
|
|
118
|
-
})
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
describe('clampPos', () => {
|
|
122
|
-
it('passes through positions within range', () => {
|
|
123
|
-
assert.equal(clampPos(5, 10), 5)
|
|
124
|
-
assert.equal(clampPos(0, 10), 0)
|
|
125
|
-
assert.equal(clampPos(10, 10), 10)
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('floors negatives at 0 and ceils overshoots at max', () => {
|
|
129
|
-
assert.equal(clampPos(-5, 10), 0)
|
|
130
|
-
assert.equal(clampPos(99, 10), 10)
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it('returns 0 for non-finite input', () => {
|
|
134
|
-
assert.equal(clampPos(NaN, 10), 0)
|
|
135
|
-
assert.equal(clampPos(Infinity, 10), 0)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
it('truncates fractional positions', () => {
|
|
139
|
-
assert.equal(clampPos(3.7, 10), 3)
|
|
140
|
-
})
|
|
141
|
-
})
|