punctilio 4.2.0 → 4.3.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "punctilio",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "description": "Smart typography transformations: curly quotes, em-dashes, en-dashes, and more",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/prose-view.ts CHANGED
@@ -121,6 +121,10 @@ class ProseViewImpl implements ProseView {
121
121
  * with a replacement starting at the same offset: the insertion's text lands
122
122
  * before the replaced span's text. Overlapping spans (and two pure
123
123
  * insertions at the same offset) are rejected.
124
+ *
125
+ * All edits are distributed in a single left-to-right pass that rebuilds
126
+ * each node's value once — per-edit string surgery would be quadratic on
127
+ * edit-dense input.
124
128
  */
125
129
  commit(): void {
126
130
  if (this.edits.length === 0) {
@@ -155,60 +159,70 @@ class ProseViewImpl implements ProseView {
155
159
  ends.push(cursor)
156
160
  }
157
161
 
158
- // Apply right-to-left so earlier offsets stay valid as we mutate.
159
- for (let i = sorted.length - 1; i >= 0; i--) {
160
- this.applyEdit(sorted[i], starts, ends)
162
+ const text = this.cachedText
163
+ const parts: string[][] = this.nodes.map(() => [])
164
+
165
+ // Rolling copy state: kept text in [0, copiedUpTo) has been distributed.
166
+ let copiedUpTo = 0
167
+ let copyNode = 0
168
+ const copyUpTo = (target: number): void => {
169
+ while (copiedUpTo < target) {
170
+ while (copyNode < this.nodes.length - 1 && ends[copyNode] <= copiedUpTo) copyNode++
171
+ const sliceEnd = Math.min(target, ends[copyNode])
172
+ parts[copyNode].push(text.slice(copiedUpTo, sliceEnd))
173
+ copiedUpTo = sliceEnd
174
+ }
161
175
  }
162
176
 
177
+ for (const edit of sorted) {
178
+ copyUpTo(edit.start)
179
+ // Deleted spans are simply never copied; replacement/insertion text
180
+ // lands in the target node, whose parts are filled exactly up to
181
+ // `edit.start` at this point.
182
+ if (edit.text.length > 0) {
183
+ const targetIndex = edit.end > edit.start
184
+ ? this.nodeContainingChar(edit.start, ends)
185
+ : this.targetNodeForInsertion(edit, ends)
186
+ parts[targetIndex].push(edit.text)
187
+ }
188
+ if (edit.end > copiedUpTo) copiedUpTo = edit.end
189
+ }
190
+ copyUpTo(text.length)
191
+
192
+ this.nodes.forEach((node, index) => {
193
+ node.value = parts[index].join("")
194
+ })
195
+
163
196
  this.edits = []
164
197
  this.refresh()
165
198
  }
166
199
 
167
- /** Index of the node whose span contains the character at `offset`. */
168
- private nodeContainingChar(offset: number, starts: number[], ends: number[]): number {
169
- for (let i = 0; i < this.nodes.length; i++) {
170
- if (offset >= starts[i] && offset < ends[i]) return i
200
+ /**
201
+ * Index of the node whose span contains the character at `offset`: the
202
+ * first node whose end lies past the offset (binary search; empty nodes at
203
+ * the offset are skipped, matching a first-containing-span linear scan).
204
+ */
205
+ private nodeContainingChar(offset: number, ends: number[]): number {
206
+ let low = 0
207
+ let high = ends.length - 1
208
+ while (low < high) {
209
+ const mid = (low + high) >>> 1
210
+ if (ends[mid] <= offset) {
211
+ low = mid + 1
212
+ } else {
213
+ high = mid
214
+ }
171
215
  }
172
- /* istanbul ignore next -- defensive: callers only pass offsets that fall within a node span */
173
- return this.nodes.length - 1
216
+ return low
174
217
  }
175
218
 
176
- private targetNodeForInsertion(edit: QueuedEdit, starts: number[], ends: number[]): number {
219
+ private targetNodeForInsertion(edit: QueuedEdit, ends: number[]): number {
177
220
  if (edit.bind === "left") {
178
221
  if (edit.start === 0) return 0
179
- return this.nodeContainingChar(edit.start - 1, starts, ends)
222
+ return this.nodeContainingChar(edit.start - 1, ends)
180
223
  }
181
224
  if (edit.start === this.cachedText.length) return this.nodes.length - 1
182
- return this.nodeContainingChar(edit.start, starts, ends)
183
- }
184
-
185
- private applyEdit(edit: QueuedEdit, starts: number[], ends: number[]): void {
186
- const { start, end, text } = edit
187
-
188
- // Decide where replacement/insertion text lands before mutating values.
189
- const targetIndex = end > start
190
- ? this.nodeContainingChar(start, starts, ends)
191
- : this.targetNodeForInsertion(edit, starts, ends)
192
- const insertOffsetInTarget = start - starts[targetIndex]
193
-
194
- // Delete the intersection of [start, end) from every covered node.
195
- for (let i = 0; i < this.nodes.length; i++) {
196
- const delStart = Math.max(start, starts[i])
197
- const delEnd = Math.min(end, ends[i])
198
- if (delStart >= delEnd) continue
199
- const node = this.nodes[i]
200
- const localStart = delStart - starts[i]
201
- const localEnd = delEnd - starts[i]
202
- node.value = node.value.slice(0, localStart) + node.value.slice(localEnd)
203
- }
204
-
205
- // Insert replacement text into the target node. For replacements the
206
- // deletion above removed [start, end) from the target, so `start` is the
207
- // correct insertion point inside the (now shortened) target value.
208
- if (text.length > 0) {
209
- const target = this.nodes[targetIndex]
210
- target.value = target.value.slice(0, insertOffsetInTarget) + text + target.value.slice(insertOffsetInTarget)
211
- }
225
+ return this.nodeContainingChar(edit.start, ends)
212
226
  }
213
227
  }
214
228