braid-text 0.5.8 → 0.5.9

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.
@@ -0,0 +1,168 @@
1
+ // Simpleton Javascript Client
2
+ //
3
+ // requires braid-http@~1.3/braid-http-client.js
4
+ // and text-client.js
5
+
6
+ // --- API ---
7
+ //
8
+ // on_update?: (update) => new_state
9
+ // called when a server update arrives. The update object contains:
10
+ // - update.patches: array of { range: [start, end], content: string }
11
+ // Ranges are UTF-16 indices (already converted from wire
12
+ // code-point offsets). Patches are sorted by range[0].
13
+ // For the initial snapshot, patches is [{ range: [0, 0], content: full_text }].
14
+ // - update.state: the new text after applying patches internally.
15
+ // - update.version, update.parents, update.extra_headers, etc.
16
+ //
17
+ // Patches have ABSOLUTE positions — each patch's range refers to
18
+ // positions in the original state (before any patches in this
19
+ // update). When applying multiple patches sequentially, you MUST
20
+ // track a cumulative offset to adjust positions:
21
+ //
22
+ // var offset = 0
23
+ // for (var p of update.patches) {
24
+ // apply_at(p.range[0] + offset, p.range[1] + offset, p.content)
25
+ // offset += p.content.length - (p.range[1] - p.range[0])
26
+ // }
27
+ //
28
+ // Must return the new text state after applying the patches.
29
+ // For simple cases, just return update.state. For UI integration
30
+ // (e.g. textarea), apply patches to the UI and return the UI's
31
+ // current text (which may include absorbed local edits).
32
+ // If not provided, simpleton uses update.state internally.
33
+ //
34
+ // get_patches?: (client_state) => patches
35
+ // returns patches representing diff between client_state and current state,
36
+ // which are guaranteed to be different if this method is being called.
37
+ // (the default does this in a fast/simple way, finding a common prefix
38
+ // and suffix, but you can supply something better, or possibly keep
39
+ // track of patches as they come from your editor)
40
+ //
41
+ // content_type: used for Accept and Content-Type headers
42
+ //
43
+ // on_error?: (error) => void
44
+ // called when an error occurs (e.g., network failure, digest mismatch)
45
+ //
46
+ // on_online?: (is_online) => void
47
+ // called when the connection status changes
48
+ //
49
+ // on_ack?: () => void
50
+ // called when all outstanding PUTs have been acknowledged
51
+ //
52
+ // send_digests?: boolean
53
+ // if truthy, includes a Repr-Digest header with each PUT
54
+ //
55
+ // returns { changed, abort }
56
+ // call changed(new_state) whenever there is a local change,
57
+ // passing the current text. The system will call get_patches
58
+ // to compute the diff (or use simple_diff if get_patches is
59
+ // not provided).
60
+ // call abort() to abort the subscription.
61
+ //
62
+ function simpleton_client(url, {
63
+ get_patches,
64
+
65
+ on_update,
66
+ on_error,
67
+ on_online,
68
+ on_ack,
69
+
70
+ headers, // The user can pass in custom headers
71
+ // that are forwarded into fetches
72
+ content_type,
73
+ send_digests
74
+ }) {
75
+ var peer = Math.random().toString(36).slice(2)
76
+ var client_version = [] // sorted version strings
77
+ var client_state = "" // text as of client_version
78
+ var char_counter = -1 // char-delta for version IDs
79
+ var is_online = false
80
+ var outstanding_puts = 0
81
+ var pending_state = null // non-null means dirty (unsent local edits)
82
+
83
+ // extend the headers with merge-type and peer
84
+ headers = { ...headers, "Merge-Type": "simpleton", Peer: peer }
85
+
86
+ var channel = reliable_update_channel(url, {
87
+ reconnect_from_parents: () => client_version.length ? client_version : null,
88
+ get_headers: { ...headers, ...content_type && {Accept: content_type} },
89
+ put_headers: { ...headers, ...content_type && {"Content-Type": content_type} },
90
+ on_update: async update => {
91
+ update.parents.sort()
92
+ if (pending_state === null && versions_eq(client_version, update.parents))
93
+ await apply_update(update)
94
+ },
95
+ on_status: status => {
96
+ is_online = status.online
97
+ outstanding_puts = status.outstanding_puts
98
+ if (on_online) on_online(is_online)
99
+ if (on_ack && outstanding_puts === 0) on_ack()
100
+ if (pending_state !== null && is_online && outstanding_puts < 10) {
101
+ if (pending_state === client_state) {
102
+ // this is a special case where the user made changes
103
+ // while offline (or too many outstanding_puts),
104
+ // but then reverted those changes,
105
+ // so we might have missed some updates that we could have applied;
106
+ // reconnecting will fetch those updates
107
+ pending_state = null
108
+ channel.reconnect()
109
+ } else {
110
+ try_send(pending_state)
111
+ }
112
+ }
113
+ },
114
+ on_error: err => on_error && on_error(err),
115
+ no_retry_status_codes: [550]
116
+ })
117
+
118
+ async function apply_update(update) {
119
+ update.patches = text_parse_update(update, client_state)
120
+ update.state = text_apply_patches(client_state, update.patches)
121
+
122
+ client_state = on_update ? on_update(update) : update.state
123
+
124
+ client_version = update.version
125
+ await check_digest(update, client_state)
126
+ }
127
+
128
+ function try_send(new_state) {
129
+ var patches = get_patches ? get_patches(client_state) :
130
+ [simple_diff(client_state, new_state)]
131
+
132
+ // Temporary: save JS-index patches to return for cursor support.
133
+ // We're thinking of removing this.
134
+ var js_patches = patches.map(p => ({range: [...p.range], content: p.content}))
135
+
136
+ var prepared = text_prepare_put(patches, client_state)
137
+ char_counter += prepared.version_count
138
+
139
+ var version = [peer + "-" + char_counter]
140
+ var parents = client_version
141
+ client_version = version
142
+ client_state = new_state
143
+ pending_state = null
144
+
145
+ var update = { version, parents, patches: prepared.patches }
146
+ if (send_digests)
147
+ get_digest(client_state).then(digest =>
148
+ channel.put({ ...update, headers: { "Repr-Digest": digest } }))
149
+ else
150
+ channel.put(update)
151
+
152
+ return js_patches // temporary, for cursor support
153
+ }
154
+
155
+ return {
156
+ abort: () => channel.close(),
157
+ changed: (new_state) => {
158
+ if (is_online && outstanding_puts < 10)
159
+ return try_send(new_state) // temporary, returns patches for cursor support
160
+ else
161
+ pending_state = new_state
162
+ }
163
+ }
164
+
165
+ function versions_eq(a, b) {
166
+ return a.length === b.length && a.every((v, i) => v === b[i])
167
+ }
168
+ }
@@ -0,0 +1,221 @@
1
+ // ============================================================
2
+ // <synced-textarea> — a textarea wired to a braid-http resource
3
+ // ============================================================
4
+ //
5
+ // A drop-in replacement for <textarea> that stays in sync with a
6
+ // braid-http resource.
7
+ //
8
+ // Depends on braid-http-client.js, simpleton.js, cursor-sync.js,
9
+ // textarea-highlights.js, and text-client.js.
10
+ //
11
+ // Attributes:
12
+ // src — the URL to sync to
13
+ // cursors — sync cursors too (default: true; set cursors="false" to disable)
14
+ // bearer — Bearer token for servers using "Authorization: Bearer X" headers
15
+ //
16
+ // The following attributes are relayed to the inner textarea:
17
+ // placeholder, readonly, rows, cols, wrap, spellcheck, autofocus,
18
+ // disabled, aria-label, aria-labelledby, aria-describedby
19
+ //
20
+ // Changes to src, cursors, or bearer restart the sync.
21
+ // Changes to any other attribute are just forwarded to the inner textarea.
22
+ //
23
+ // You can always manipulate the inner textarea directly via the .textarea property.
24
+
25
+
26
+ // Attributes in this list restart the sync when they change.
27
+ const CONNECTION_ATTRS = ['src', 'bearer', 'cursors']
28
+
29
+ // Attributes in this list are relayed straight to the inner textarea.
30
+ const FORWARD_ATTRS = ['placeholder', 'readonly', 'rows', 'cols',
31
+ 'wrap', 'spellcheck', 'autofocus',
32
+ 'aria-label', 'aria-labelledby', 'aria-describedby']
33
+
34
+
35
+ class SyncedTextarea extends HTMLElement {
36
+
37
+ static observedAttributes = [...CONNECTION_ATTRS, ...FORWARD_ATTRS, 'disabled']
38
+
39
+ // DOM lifecycle hooks — dispatched to the methods in the sections below.
40
+ connectedCallback() { this.connect() }
41
+ disconnectedCallback() { this.disconnect() }
42
+ attributeChangedCallback(name, oldValue, newValue) {
43
+ if (!this.isConnected || oldValue === newValue) return
44
+ if (CONNECTION_ATTRS.includes(name)) {
45
+ this.disconnect()
46
+ this.connect()
47
+ } else if (name === 'disabled')
48
+ this.update_disabled()
49
+ else
50
+ this.forward_attribute(name)
51
+ }
52
+
53
+
54
+ // ════════════════════════════════════════════════════════════
55
+ // Syncing the textarea
56
+ // ════════════════════════════════════════════════════════════
57
+
58
+ // Builds the inner textarea, wires up events, starts simpleton_client
59
+ // and cursor_highlights if enabled.
60
+ connect() {
61
+ if (this.firstChild)
62
+ console.warn('<synced-textarea> ignoring existing child content')
63
+ while (this.firstChild) this.removeChild(this.firstChild)
64
+
65
+ // Create the inner textarea, and fill the outer element with it.
66
+ var textarea = document.createElement('textarea')
67
+ textarea.style.width = '100%'
68
+ textarea.style.height = '100%'
69
+ textarea.style.boxSizing = 'border-box'
70
+ this.appendChild(textarea)
71
+ this.textarea = textarea
72
+
73
+ // Mirror all pass-through attributes to the inner textarea.
74
+ for (var attr of FORWARD_ATTRS) this.forward_attribute(attr)
75
+
76
+ // Start disabled until we receive the first update from the server,
77
+ // so the user can't type before we know what they're editing.
78
+ this.waiting_for_first_update = true
79
+ this.has_error = false
80
+ this.update_disabled()
81
+
82
+ // Re-dispatch the inner textarea's focus/blur events on the outer,
83
+ // so listeners on <synced-textarea> see them.
84
+ textarea.addEventListener('focus', () =>
85
+ this.dispatchEvent(new FocusEvent('focus')))
86
+ textarea.addEventListener('blur', () =>
87
+ this.dispatchEvent(new FocusEvent('blur')))
88
+
89
+ // When a <label for="x"> targets us, the browser fires a click on
90
+ // us. Forward focus to the inner textarea.
91
+ this.addEventListener('click', (e) => {
92
+ if (e.target === this) textarea.focus()
93
+ })
94
+
95
+ // Nothing to sync if there's no src.
96
+ var url = this.getAttribute('src')
97
+ if (!url) return
98
+
99
+ // Headers for all requests.
100
+ var headers = {}
101
+ var bearer = this.getAttribute('bearer')
102
+ if (bearer) headers['Authorization'] = 'Bearer ' + bearer
103
+
104
+ // Cursor sync defaults to on; set cursors="false" to disable.
105
+ var cursors = this.getAttribute('cursors') !== 'false'
106
+ ? cursor_highlights(textarea, url, { headers }) : null
107
+ this.cursors = cursors
108
+
109
+ // The main sync client.
110
+ this.client = simpleton_client(url, {
111
+ headers,
112
+ on_online: (online) => { online ? cursors?.online() : cursors?.offline() },
113
+ on_update: (update) => {
114
+ this.waiting_for_first_update = false
115
+ this.update_disabled()
116
+ apply_patches_and_update_selection(textarea, update.patches)
117
+ cursors?.on_patches(update.patches)
118
+ this.dispatchEvent(new CustomEvent('remoteupdate', { detail: { patches: update.patches } }))
119
+ this.dispatchEvent(new CustomEvent('update', { detail: { patches: update.patches } }))
120
+ return textarea.value
121
+ },
122
+ get_patches: (prev) => diff(prev, textarea.value),
123
+ on_error: () => {
124
+ this.has_error = true
125
+ this.update_disabled()
126
+ set_error_state(textarea)
127
+ },
128
+ on_ack: () => set_acked_state(textarea)
129
+ })
130
+
131
+ // Local edits get relayed to the client and cursors.
132
+ this.oninput_handler = () => {
133
+ set_acked_state(textarea, false)
134
+ var patches = this.client.changed(textarea.value)
135
+ cursors?.on_edit(patches)
136
+ this.dispatchEvent(new CustomEvent('update', { detail: { patches } }))
137
+ }
138
+ textarea.addEventListener('input', this.oninput_handler)
139
+ }
140
+
141
+ // Tear everything down.
142
+ disconnect() {
143
+ this.client?.abort()
144
+ this.cursors?.destroy()
145
+ if (this.textarea) {
146
+ this.textarea.removeEventListener('input', this.oninput_handler)
147
+ this.textarea.remove()
148
+ }
149
+ this.client = this.cursors = this.textarea = this.oninput_handler = null
150
+ this.waiting_for_first_update = this.has_error = false
151
+ }
152
+
153
+
154
+ // ════════════════════════════════════════════════════════════
155
+ // Emulating an inner textarea with the outer sync-textarea
156
+ // ════════════════════════════════════════════════════════════
157
+ // So code that works with a <textarea> works with us too — the
158
+ // same properties, methods, and events should behave identically.
159
+
160
+ // Attribute forwarding — mirrors attrs from outer to inner textarea.
161
+ forward_attribute(name) {
162
+ if (!this.textarea) return
163
+ if (this.hasAttribute(name))
164
+ this.textarea.setAttribute(name, this.getAttribute(name))
165
+ else
166
+ this.textarea.removeAttribute(name)
167
+ }
168
+
169
+ // Effective disabled state = user's `disabled` attribute OR our own
170
+ // internal state (still connecting, or erroring).
171
+ update_disabled() {
172
+ if (!this.textarea) return
173
+ this.textarea.disabled = this.hasAttribute('disabled')
174
+ || this.waiting_for_first_update
175
+ || this.has_error
176
+ }
177
+
178
+ // Property forwarding — .value, .selectionStart, .selectionEnd, .selectionDirection.
179
+ // Setting .value triggers client.changed() since textarea.value = ... doesn't fire
180
+ // the 'input' event that our input listener relies on.
181
+ get value() { return this.textarea?.value ?? '' }
182
+ set value(v) {
183
+ if (!this.textarea) return
184
+ this.textarea.value = v
185
+ this.client?.changed()
186
+ }
187
+
188
+ get selectionStart() { return this.textarea?.selectionStart ?? 0 }
189
+ set selectionStart(v) { if (this.textarea) this.textarea.selectionStart = v }
190
+
191
+ get selectionEnd() { return this.textarea?.selectionEnd ?? 0 }
192
+ set selectionEnd(v) { if (this.textarea) this.textarea.selectionEnd = v }
193
+
194
+ get selectionDirection() { return this.textarea?.selectionDirection ?? 'none' }
195
+ set selectionDirection(v) { if (this.textarea) this.textarea.selectionDirection = v }
196
+
197
+ get textLength() { return this.textarea?.textLength ?? 0 }
198
+
199
+ // Scroll forwarding — the inner textarea is the scroll container.
200
+ get scrollTop() { return this.textarea?.scrollTop ?? 0 }
201
+ set scrollTop(v) { if (this.textarea) this.textarea.scrollTop = v }
202
+
203
+ get scrollLeft() { return this.textarea?.scrollLeft ?? 0 }
204
+ set scrollLeft(v) { if (this.textarea) this.textarea.scrollLeft = v }
205
+
206
+ get scrollHeight() { return this.textarea?.scrollHeight ?? 0 }
207
+ get scrollWidth() { return this.textarea?.scrollWidth ?? 0 }
208
+
209
+ // Method forwarding — .focus(), .blur(), .select(), .setSelectionRange(), .setRangeText(),
210
+ // .scrollTo(), .scrollBy().
211
+ focus(options) { this.textarea?.focus(options) }
212
+ blur() { this.textarea?.blur() }
213
+ select() { this.textarea?.select() }
214
+ setSelectionRange(...a) { this.textarea?.setSelectionRange(...a) }
215
+ setRangeText(...a) { this.textarea?.setRangeText(...a) }
216
+ scrollTo(...a) { this.textarea?.scrollTo(...a) }
217
+ scrollBy(...a) { this.textarea?.scrollBy(...a) }
218
+ }
219
+
220
+
221
+ customElements.define('synced-textarea', SyncedTextarea)
@@ -0,0 +1,269 @@
1
+ // Text utility functions for simpleton text clients
2
+ //
3
+ // Used by simpleton.js and directly by editors.
4
+
5
+
6
+ // ── UTF-16 / Unicode code-point helpers ────────────────────────────────
7
+ // JS strings are UTF-16. Characters outside the Basic Multilingual Plane
8
+ // (BMP) are encoded as two 16-bit code units (a surrogate pair: high
9
+ // 0xD800-0xDBFF, low 0xDC00-0xDFFF). Such a pair represents one Unicode
10
+ // code point.
11
+ //
12
+ // OTHER LANGUAGES: You don't need these if your string type is natively
13
+ // indexed by code points.
14
+
15
+ function get_char_size(str, utf16_index) {
16
+ const char_code = str.charCodeAt(utf16_index)
17
+ return (char_code >= 0xd800 && char_code <= 0xdbff) ? 2 : 1
18
+ }
19
+
20
+ function count_code_points(str) {
21
+ let code_points = 0
22
+ for (let i = 0; i < str.length; i++) {
23
+ if (str.charCodeAt(i) >= 0xd800 && str.charCodeAt(i) <= 0xdbff) i++
24
+ code_points++
25
+ }
26
+ return code_points
27
+ }
28
+
29
+ function is_low_surrogate(str, i) {
30
+ var c = str.charCodeAt(i)
31
+ return c >= 0xdc00 && c <= 0xdfff
32
+ }
33
+
34
+ function is_high_surrogate(str, i) {
35
+ var c = str.charCodeAt(i)
36
+ return c >= 0xd800 && c <= 0xdbff
37
+ }
38
+
39
+ // Converts patch ranges from code-point offsets to UTF-16 indices.
40
+ // Patches must be sorted by range[0].
41
+ function convert_ranges_codepoints_to_utf16(patches, str) {
42
+ let codepoint_index = 0
43
+ let utf16_index = 0
44
+ for (let patch of patches) {
45
+ while (codepoint_index < patch.range[0]) {
46
+ utf16_index += get_char_size(str, utf16_index)
47
+ codepoint_index++
48
+ }
49
+ patch.range[0] = utf16_index
50
+
51
+ while (codepoint_index < patch.range[1]) {
52
+ utf16_index += get_char_size(str, utf16_index)
53
+ codepoint_index++
54
+ }
55
+ patch.range[1] = utf16_index
56
+ }
57
+ }
58
+
59
+ // Converts patch ranges from UTF-16 indices to code-point offsets.
60
+ // Patches must be sorted by range[0].
61
+ function convert_ranges_utf16_to_codepoints(patches, str) {
62
+ let codepoint_index = 0
63
+ let utf16_index = 0
64
+ for (let patch of patches) {
65
+ while (utf16_index < patch.range[0]) {
66
+ utf16_index += get_char_size(str, utf16_index)
67
+ codepoint_index++
68
+ }
69
+ patch.range[0] = codepoint_index
70
+
71
+ while (utf16_index < patch.range[1]) {
72
+ utf16_index += get_char_size(str, utf16_index)
73
+ codepoint_index++
74
+ }
75
+ patch.range[1] = codepoint_index
76
+ }
77
+ }
78
+
79
+ // ── text_apply_patches ─────────────────────────────────────────────────
80
+ // Applies patches to a string, tracking cumulative offset.
81
+ //
82
+ // Patches must have absolute coordinates (relative to the original
83
+ // string, not to the string after previous patches). The offset
84
+ // variable tracks the cumulative shift from previous patches.
85
+ function text_apply_patches(state, patches) {
86
+ var offset = 0
87
+ for (var patch of patches) {
88
+ state = state.substring(0, patch.range[0] + offset) + patch.content +
89
+ state.substring(patch.range[1] + offset)
90
+ offset += patch.content.length - (patch.range[1] - patch.range[0])
91
+ }
92
+ return state
93
+ }
94
+
95
+
96
+ // ── Digest helpers ─────────────────────────────────────────────────────
97
+
98
+ // Computes SHA-256 of the UTF-8 encoding of the string
99
+ // Formatted as: sha-256=:<base64-encoded-hash>:
100
+ async function get_digest(str) {
101
+ var bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str))
102
+ return `sha-256=:${btoa(String.fromCharCode(...new Uint8Array(bytes)))}:`
103
+ }
104
+
105
+ // Makes sure the current state matches the digest in the update
106
+ async function check_digest(update, client_state) {
107
+ if (update.extra_headers?.["repr-digest"]?.startsWith('sha-256=')
108
+ && update.extra_headers["repr-digest"] !== await get_digest(client_state)) {
109
+ console.log('repr-digest mismatch!')
110
+ console.log('repr-digest: ' + update.extra_headers["repr-digest"])
111
+ console.log('state: ' + client_state)
112
+ throw new Error('repr-digest mismatch')
113
+ }
114
+ }
115
+
116
+
117
+ // ── Text update parsing / serialization ────────────────────────────────
118
+
119
+ // Parse a wire update into text patches (with UTF-16 ranges)
120
+ function text_parse_update(update, client_state) {
121
+ if (update.patches) {
122
+ for (let patch of update.patches) {
123
+ patch.range = patch.range.match(/\d+/g).map((x) => 1 * x)
124
+ patch.content = patch.content_text
125
+ }
126
+ var patches = update.patches.sort((a, b) => a.range[0] - b.range[0])
127
+ convert_ranges_codepoints_to_utf16(patches, client_state)
128
+ return patches
129
+ } else
130
+ return [{range: [0, 0], content: update.body_text}]
131
+ }
132
+
133
+ // Prepare text patches for the wire (converts to code-point ranges,
134
+ // formats as simpleton text patches). Returns { patches, version_count }.
135
+ function text_prepare_put(patches, client_state) {
136
+ convert_ranges_utf16_to_codepoints(patches, client_state)
137
+ var version_count = 0
138
+ for (let patch of patches) {
139
+ version_count += patch.range[1] - patch.range[0]
140
+ version_count += count_code_points(patch.content)
141
+ patch.unit = "text"
142
+ patch.range = `[${patch.range[0]}:${patch.range[1]}]`
143
+ }
144
+ return { patches, version_count }
145
+ }
146
+
147
+
148
+ // ── UI helpers ─────────────────────────────────────────────────────────
149
+
150
+ function set_acked_state(textarea, on = true) {
151
+ if (on)
152
+ textarea.style.caretColor = textarea.old_caretColor
153
+ else {
154
+ textarea.old_caretColor = textarea.style.caretColor
155
+ textarea.style.caretColor = 'red'
156
+ }
157
+ }
158
+
159
+ function set_error_state(textarea, on = true) {
160
+ if (on) {
161
+ textarea.old_disabled = textarea.disabled
162
+ textarea.old_background = textarea.style.background
163
+ textarea.old_border = textarea.style.border
164
+
165
+ textarea.disabled = true
166
+ textarea.style.background = '#fee'
167
+ textarea.style.border = '4px solid red'
168
+ } else {
169
+ textarea.disabled = textarea.old_disabled
170
+ textarea.style.background = textarea.old_background
171
+ textarea.style.border = textarea.old_border
172
+ }
173
+ }
174
+
175
+ // A convenient wrapper around the myers-diff.js library's "diff_main()" function,
176
+ // which is defined in https://braid.org/code/myers-diff1.js.
177
+ function diff(before, after) {
178
+ let diff = diff_main(before, after)
179
+ let patches = []
180
+ let offset = 0
181
+ for (let d of diff) {
182
+ let p = null
183
+ if (d[0] === 1) p = { range: [offset, offset], content: d[1] }
184
+ else if (d[0] === -1) {
185
+ p = { range: [offset, offset + d[1].length], content: "" }
186
+ offset += d[1].length
187
+ } else offset += d[1].length
188
+ if (p) {
189
+ p.unit = "text"
190
+ patches.push(p)
191
+ }
192
+ }
193
+ return patches
194
+ }
195
+
196
+ // ── simple_diff ────────────────────────────────────────────────────────
197
+ // Finds the longest common prefix and suffix between two strings,
198
+ // returning the minimal edit that transforms `old_str` into `new_str`.
199
+ //
200
+ // Returns: { range: [prefix_len, old_str.length - suffix_len],
201
+ // content: new_str.slice(prefix_len, new_str.length - suffix_len) }
202
+ //
203
+ // This produces a single contiguous edit. For multi-cursor or
204
+ // multi-region edits, supply a custom get_patches function instead.
205
+ function simple_diff(old_str, new_str) {
206
+ var prefix_len = 0
207
+ var min_len = Math.min(old_str.length, new_str.length)
208
+ while (prefix_len < min_len && old_str[prefix_len] === new_str[prefix_len]) prefix_len++
209
+
210
+ // Don't split a surrogate pair: if prefix ends on a low surrogate,
211
+ // the preceding high surrogate only matched by coincidence (same
212
+ // Unicode block), so back up to include the whole pair in the diff.
213
+ if (prefix_len > 0 && is_low_surrogate(old_str, prefix_len))
214
+ prefix_len--
215
+
216
+ // Find common suffix length (from what remains after prefix)
217
+ var suffix_len = 0
218
+ min_len -= prefix_len
219
+ while (suffix_len < min_len
220
+ && (old_str[old_str.length - suffix_len - 1]
221
+ === new_str[new_str.length - suffix_len - 1]))
222
+ suffix_len++
223
+
224
+ // Same guard for suffixes: if the range end (old_str.length - suffix_len)
225
+ // lands on a low surrogate, the suffix consumed it without its high
226
+ // surrogate, so back up.
227
+ if (suffix_len > 0 && is_low_surrogate(old_str, old_str.length - suffix_len))
228
+ suffix_len--
229
+
230
+ return {range: [prefix_len, old_str.length - suffix_len],
231
+ content: new_str.slice(prefix_len, new_str.length - suffix_len)}
232
+ }
233
+
234
+
235
+ function apply_patches_and_update_selection(textarea, patches) {
236
+ let offset = 0
237
+ for (let p of patches) {
238
+ p.range[0] += offset
239
+ p.range[1] += offset
240
+ offset -= p.range[1] - p.range[0]
241
+ offset += p.content.length
242
+ }
243
+
244
+ let original = textarea.value
245
+ let sel = [textarea.selectionStart, textarea.selectionEnd]
246
+
247
+ for (var p of patches) {
248
+ let range = p.range
249
+
250
+ for (let i = 0; i < sel.length; i++)
251
+ if (sel[i] > range[0])
252
+ if (sel[i] > range[1])
253
+ sel[i] -= range[1] - range[0]
254
+ else
255
+ sel[i] = range[0]
256
+
257
+ for (let i = 0; i < sel.length; i++)
258
+ if (sel[i] > range[0])
259
+ sel[i] += p.content.length
260
+
261
+ original = original.substring(0, range[0])
262
+ + p.content
263
+ + original.substring(range[1])
264
+ }
265
+
266
+ textarea.value = original
267
+ textarea.selectionStart = sel[0]
268
+ textarea.selectionEnd = sel[1]
269
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "braid-text",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Library for collaborative text over http using braid.",
5
5
  "author": "Braid Working Group",
6
6
  "repository": "braid-org/braid-text",
@@ -14,10 +14,12 @@
14
14
  "server.js",
15
15
  "server.d.ts",
16
16
  "simpleton-client.js",
17
- "client/simpleton-sync.js",
17
+ "client/simpleton.js",
18
18
  "client/cursor-sync.js",
19
19
  "client/textarea-highlights.js",
20
- "client/web-utils.js",
20
+ "client/text-client.js",
21
+ "client/syncarea.js",
22
+ "client/myers-diff.js",
21
23
  "README.md",
22
24
  "client/editor.html",
23
25
  "client/markdown-editor.html",