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.
- package/client/editor.html +7 -26
- package/client/markdown-editor.html +15 -47
- package/client/myers-diff.js +691 -0
- package/client/simpleton.js +168 -0
- package/client/syncarea.js +221 -0
- package/client/text-client.js +269 -0
- package/package.json +5 -3
- package/server-demo.js +11 -3
- package/client/simpleton-sync.js +0 -434
- package/client/web-utils.js +0 -76
|
@@ -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.
|
|
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
|
|
17
|
+
"client/simpleton.js",
|
|
18
18
|
"client/cursor-sync.js",
|
|
19
19
|
"client/textarea-highlights.js",
|
|
20
|
-
"client/
|
|
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",
|