tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
more bugs squashed, getting less shit
Orual
2 months ago
df2ba14f
03aa2553
+346
-189
3 changed files
expand all
collapse all
unified
split
crates
weaver-app
src
components
editor
mod.rs
render.rs
writer.rs
+176
-121
crates/weaver-app/src/components/editor/mod.rs
···
71
.collect::<Vec<_>>()
72
});
73
74
-
// Track previous paragraphs for change detection (outside effect so it persists)
75
-
let mut prev_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
76
77
// Update DOM when paragraphs change (incremental rendering)
78
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···
80
let new_paras = paragraphs();
81
let cursor_offset = document().cursor.offset;
82
83
-
// Use peek() to avoid creating reactive dependency on prev_paragraphs
84
-
let prev = prev_paragraphs.peek().clone();
85
86
let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset);
87
···
108
}
109
}
110
111
-
// Store for next comparison (write-only, no reactive read)
112
-
prev_paragraphs.set(new_paras);
113
});
114
115
// Auto-save with debounce
···
149
},
150
151
onkeyup: move |evt| {
152
-
// After any key (including arrow keys), sync cursor from DOM
153
-
sync_cursor_from_dom(&mut document, editor_id);
0
0
0
0
0
0
0
0
0
0
154
},
155
156
onclick: move |_evt| {
157
// After mouse click, sync cursor from DOM
158
-
sync_cursor_from_dom(&mut document, editor_id);
0
159
},
160
161
onpaste: move |evt| {
162
-
evt.prevent_default();
163
handle_paste(evt, &mut document);
0
0
0
0
164
},
165
}
166
···
186
let key = evt.key();
187
let mods = evt.modifiers();
188
189
-
// Intercept shortcuts
190
if mods.ctrl() || mods.meta() {
191
-
return true;
0
0
0
0
0
192
}
193
194
// Intercept content modifications
···
198
)
199
}
200
201
-
/// Sync internal cursor state from browser DOM selection
202
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
203
-
fn sync_cursor_from_dom(document: &mut Signal<EditorDocument>, editor_id: &str) {
0
0
0
0
204
use wasm_bindgen::JsCast;
0
0
0
0
0
205
206
let window = match web_sys::window() {
207
Some(w) => w,
···
213
None => return,
214
};
215
216
-
// Get editor element as boundary for search
217
let editor_element = match dom_document.get_element_by_id(editor_id) {
218
Some(e) => e,
219
None => return,
···
224
_ => return,
225
};
226
227
-
// Get cursor position from selection
0
0
0
0
228
let focus_node = match selection.focus_node() {
229
Some(node) => node,
230
None => return,
231
};
0
0
232
233
-
let focus_offset = selection.focus_offset() as usize;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
234
235
-
// Find the text node's containing element with an ID (from offset map)
236
-
// Walk up but stop at editor boundary to avoid escaping the editor
237
-
let mut current_node = focus_node.clone();
0
0
0
0
0
0
0
0
0
0
238
let node_id = loop {
239
if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
240
-
// Stop if we've reached the editor boundary
241
-
if element == &editor_element {
242
break None;
243
}
244
245
-
// Check both id and data-node-id attributes
246
-
// (paragraphs use id, headings use data-node-id to preserve user heading IDs)
247
let id = element
248
.get_attribute("id")
249
.or_else(|| element.get_attribute("data-node-id"));
250
251
if let Some(id) = id {
252
-
// Look for node IDs like "n0", "n1", etc (from offset map)
253
if id.starts_with('n') && id[1..].parse::<usize>().is_ok() {
254
break Some(id);
255
}
256
}
257
}
258
259
-
current_node = match current_node.parent_node() {
260
-
Some(parent) => parent,
261
-
None => break None,
262
-
};
263
};
264
265
-
let node_id = match node_id {
266
-
Some(id) => id,
267
-
None => {
268
-
tracing::warn!("Could not find node_id for cursor position");
269
-
return;
270
-
}
271
-
};
272
273
-
let container = match dom_document.get_element_by_id(&node_id).or_else(|| {
0
274
let selector = format!("[data-node-id='{}']", node_id);
275
dom_document.query_selector(&selector).ok().flatten()
276
-
}) {
277
-
Some(e) => e,
278
-
None => return,
279
-
};
280
281
-
// Calculate UTF-16 offset from start of container to focus position
282
let mut utf16_offset_in_container = 0;
283
284
-
// Create tree walker for text nodes in container
285
if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) {
286
-
while let Ok(Some(node)) = walker.next_node() {
287
-
if node == focus_node {
288
-
// Found the exact text node, add the offset within it
289
-
utf16_offset_in_container += focus_offset;
290
break;
291
}
292
293
-
// Accumulate length of previous text nodes
294
-
if let Some(text) = node.text_content() {
295
utf16_offset_in_container += text.encode_utf16().count();
296
}
297
}
298
}
299
300
-
// Now look up this position in the offset map
301
-
// We need to find the mapping with this node_id and calculate rope offset
302
-
document.with_mut(|doc| {
303
-
// Render to get current offset maps
304
-
let paragraphs = render::render_paragraphs(&doc.rope);
305
-
306
-
tracing::debug!("[SYNC] Looking for node_id: {}, utf16_offset_in_container: {}", node_id, utf16_offset_in_container);
307
-
308
-
// Find mapping with this node_id
309
-
for para in paragraphs {
310
-
for mapping in para.offset_map {
311
-
if mapping.node_id == node_id {
312
-
// Check if our utf16 offset falls within this mapping's range
313
-
// End-INCLUSIVE to allow cursor at the end of text nodes
314
-
let mapping_start = mapping.char_offset_in_node;
315
-
let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
316
-
317
-
if utf16_offset_in_container >= mapping_start && utf16_offset_in_container <= mapping_end {
318
-
// Calculate rope offset
319
-
let offset_in_mapping = utf16_offset_in_container - mapping_start;
320
-
let rope_offset = mapping.char_range.start + offset_in_mapping;
321
322
-
tracing::debug!("[SYNC] -> MATCHED! rope_offset: {} (was {})", rope_offset, doc.cursor.offset);
323
-
doc.cursor.offset = rope_offset;
324
-
return;
325
-
}
0
326
}
327
}
328
}
0
329
330
-
tracing::warn!("Could not map DOM cursor position to rope offset");
331
-
});
332
}
333
334
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
335
-
fn sync_cursor_from_dom(_document: &mut Signal<EditorDocument>, _editor_id: &str) {
0
0
0
0
336
// No-op on non-wasm
337
}
338
339
/// Handle paste events and insert text at cursor
340
fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
341
-
// Downcast to web_sys event to get clipboard data
342
-
#[cfg(target_arch = "wasm32")]
343
-
if let Some(web_evt) = evt.data().downcast::<web_sys::ClipboardEvent>() {
344
-
if let Some(data_transfer) = web_evt.clipboard_data() {
345
-
if let Ok(text) = data_transfer.get_data("text/plain") {
346
-
document.with_mut(|doc| {
347
-
// Delete selection if present
348
-
if let Some(sel) = doc.selection {
349
-
let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
350
-
doc.rope.remove(start..end);
351
-
doc.cursor.offset = start;
352
-
doc.selection = None;
353
-
}
354
355
-
// Insert pasted text
356
-
doc.rope.insert(doc.cursor.offset, &text);
357
-
doc.cursor.offset += text.chars().count();
358
-
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
359
}
0
0
360
}
361
}
362
}
363
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
364
/// Handle keyboard events and update document state
365
fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) {
366
use dioxus::prelude::keyboard_types::Key;
···
489
doc.rope.insert(doc.cursor.offset, " \n\u{200C}");
490
doc.cursor.offset += 3;
491
} else {
492
-
// Enter: paragraph break (much cleaner, less jank)
493
-
tracing::info!(
494
-
"[ENTER] Before insert - cursor at {}, rope len {}",
495
-
doc.cursor.offset,
496
-
doc.len_chars()
497
-
);
498
doc.rope.insert(doc.cursor.offset, "\n\n");
499
doc.cursor.offset += 2;
500
-
tracing::info!(
501
-
"[ENTER] After insert - cursor at {}, rope len {}",
502
-
doc.cursor.offset,
503
-
doc.len_chars()
504
-
);
505
}
506
}
507
···
611
let cursor_para_idx = new_paragraphs
612
.iter()
613
.position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end);
614
-
615
-
tracing::info!(
616
-
"[DOM] cursor_offset = {}, cursor_para_idx = {:?}",
617
-
cursor_offset,
618
-
cursor_para_idx
619
-
);
620
-
for (idx, para) in new_paragraphs.iter().enumerate() {
621
-
let matches =
622
-
para.char_range.start <= cursor_offset && cursor_offset <= para.char_range.end;
623
-
tracing::info!(
624
-
"[DOM] para {}: char_range {:?}, matches cursor? {}",
625
-
idx,
626
-
para.char_range,
627
-
matches
628
-
);
629
-
}
630
631
let mut cursor_para_updated = false;
632
···
71
.collect::<Vec<_>>()
72
});
73
74
+
// Cache paragraphs for change detection AND for event handlers to access
75
+
let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
76
77
// Update DOM when paragraphs change (incremental rendering)
78
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···
80
let new_paras = paragraphs();
81
let cursor_offset = document().cursor.offset;
82
83
+
// Use peek() to avoid creating reactive dependency on cached_paragraphs
84
+
let prev = cached_paragraphs.peek().clone();
85
86
let cursor_para_updated = update_paragraph_dom(editor_id, &prev, &new_paras, cursor_offset);
87
···
108
}
109
}
110
111
+
// Store for next comparison AND for event handlers (write-only, no reactive read)
112
+
cached_paragraphs.set(new_paras);
113
});
114
115
// Auto-save with debounce
···
149
},
150
151
onkeyup: move |evt| {
152
+
use dioxus::prelude::keyboard_types::Key;
153
+
// Only sync cursor from DOM after navigation keys
154
+
// Content-modifying keys update cursor directly in handle_keydown
155
+
let dominated = matches!(
156
+
evt.key(),
157
+
Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown |
158
+
Key::Home | Key::End | Key::PageUp | Key::PageDown
159
+
);
160
+
if dominated {
161
+
let paras = cached_paragraphs();
162
+
sync_cursor_from_dom(&mut document, editor_id, ¶s);
163
+
}
164
},
165
166
onclick: move |_evt| {
167
// After mouse click, sync cursor from DOM
168
+
let paras = cached_paragraphs();
169
+
sync_cursor_from_dom(&mut document, editor_id, ¶s);
170
},
171
172
onpaste: move |evt| {
0
173
handle_paste(evt, &mut document);
174
+
},
175
+
176
+
oncut: move |evt| {
177
+
handle_cut(evt, &mut document);
178
},
179
}
180
···
200
let key = evt.key();
201
let mods = evt.modifiers();
202
203
+
// Handle Ctrl/Cmd shortcuts
204
if mods.ctrl() || mods.meta() {
205
+
if let Key::Character(ch) = &key {
206
+
// Intercept our formatting shortcuts (Ctrl+B, Ctrl+I)
207
+
return matches!(ch.as_str(), "b" | "i");
208
+
}
209
+
// Let browser handle other Ctrl/Cmd shortcuts (paste, copy, cut, undo, etc.)
210
+
return false;
211
}
212
213
// Intercept content modifications
···
217
)
218
}
219
220
+
/// Sync internal cursor and selection state from browser DOM selection
221
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
222
+
fn sync_cursor_from_dom(
223
+
document: &mut Signal<EditorDocument>,
224
+
editor_id: &str,
225
+
paragraphs: &[ParagraphRender],
226
+
) {
227
use wasm_bindgen::JsCast;
228
+
229
+
// Early return if paragraphs not yet populated (first render edge case)
230
+
if paragraphs.is_empty() {
231
+
return;
232
+
}
233
234
let window = match web_sys::window() {
235
Some(w) => w,
···
241
None => return,
242
};
243
0
244
let editor_element = match dom_document.get_element_by_id(editor_id) {
245
Some(e) => e,
246
None => return,
···
251
_ => return,
252
};
253
254
+
// Get both anchor (selection start) and focus (selection end) positions
255
+
let anchor_node = match selection.anchor_node() {
256
+
Some(node) => node,
257
+
None => return,
258
+
};
259
let focus_node = match selection.focus_node() {
260
Some(node) => node,
261
None => return,
262
};
263
+
let anchor_offset = selection.anchor_offset() as usize;
264
+
let focus_offset = selection.focus_offset() as usize;
265
266
+
// Convert both DOM positions to rope offsets using cached paragraphs
267
+
let anchor_rope = dom_position_to_rope_offset(
268
+
&dom_document,
269
+
&editor_element,
270
+
&anchor_node,
271
+
anchor_offset,
272
+
paragraphs,
273
+
);
274
+
let focus_rope = dom_position_to_rope_offset(
275
+
&dom_document,
276
+
&editor_element,
277
+
&focus_node,
278
+
focus_offset,
279
+
paragraphs,
280
+
);
281
+
282
+
document.with_mut(|doc| {
283
+
match (anchor_rope, focus_rope) {
284
+
(Some(anchor), Some(focus)) => {
285
+
doc.cursor.offset = focus;
286
+
if anchor != focus {
287
+
// There's an actual selection
288
+
doc.selection = Some(Selection {
289
+
anchor,
290
+
head: focus,
291
+
});
292
+
tracing::debug!("[SYNC] Selection {}..{}", anchor, focus);
293
+
} else {
294
+
// Collapsed selection (just cursor)
295
+
doc.selection = None;
296
+
tracing::debug!("[SYNC] Cursor at {}", focus);
297
+
}
298
+
}
299
+
_ => {
300
+
tracing::warn!("Could not map DOM selection to rope offsets");
301
+
}
302
+
}
303
+
});
304
+
}
305
306
+
/// Convert a DOM position (node + offset) to a rope char offset using offset maps
307
+
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
308
+
fn dom_position_to_rope_offset(
309
+
dom_document: &web_sys::Document,
310
+
editor_element: &web_sys::Element,
311
+
node: &web_sys::Node,
312
+
offset_in_text_node: usize,
313
+
paragraphs: &[ParagraphRender],
314
+
) -> Option<usize> {
315
+
use wasm_bindgen::JsCast;
316
+
317
+
// Find the containing element with a node ID (walk up from text node)
318
+
let mut current_node = node.clone();
319
let node_id = loop {
320
if let Some(element) = current_node.dyn_ref::<web_sys::Element>() {
321
+
if element == editor_element {
0
322
break None;
323
}
324
0
0
325
let id = element
326
.get_attribute("id")
327
.or_else(|| element.get_attribute("data-node-id"));
328
329
if let Some(id) = id {
0
330
if id.starts_with('n') && id[1..].parse::<usize>().is_ok() {
331
break Some(id);
332
}
333
}
334
}
335
336
+
current_node = current_node.parent_node()?;
0
0
0
337
};
338
339
+
let node_id = node_id?;
0
0
0
0
0
0
340
341
+
// Get the container element
342
+
let container = dom_document.get_element_by_id(&node_id).or_else(|| {
343
let selector = format!("[data-node-id='{}']", node_id);
344
dom_document.query_selector(&selector).ok().flatten()
345
+
})?;
0
0
0
346
347
+
// Calculate UTF-16 offset from start of container to the position
348
let mut utf16_offset_in_container = 0;
349
0
350
if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) {
351
+
while let Ok(Some(text_node)) = walker.next_node() {
352
+
if &text_node == node {
353
+
utf16_offset_in_container += offset_in_text_node;
0
354
break;
355
}
356
357
+
if let Some(text) = text_node.text_content() {
0
358
utf16_offset_in_container += text.encode_utf16().count();
359
}
360
}
361
}
362
363
+
// Look up in offset maps
364
+
for para in paragraphs {
365
+
for mapping in ¶.offset_map {
366
+
if mapping.node_id == node_id {
367
+
let mapping_start = mapping.char_offset_in_node;
368
+
let mapping_end = mapping.char_offset_in_node + mapping.utf16_len;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
369
370
+
if utf16_offset_in_container >= mapping_start
371
+
&& utf16_offset_in_container <= mapping_end
372
+
{
373
+
let offset_in_mapping = utf16_offset_in_container - mapping_start;
374
+
return Some(mapping.char_range.start + offset_in_mapping);
375
}
376
}
377
}
378
+
}
379
380
+
None
0
381
}
382
383
#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
384
+
fn sync_cursor_from_dom(
385
+
_document: &mut Signal<EditorDocument>,
386
+
_editor_id: &str,
387
+
_paragraphs: &[ParagraphRender],
388
+
) {
389
// No-op on non-wasm
390
}
391
392
/// Handle paste events and insert text at cursor
393
fn handle_paste(evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
394
+
tracing::info!("[PASTE] handle_paste called");
0
0
0
0
0
0
0
0
0
0
0
0
395
396
+
#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
397
+
{
398
+
use dioxus::web::WebEventExt;
399
+
use wasm_bindgen::JsCast;
400
+
401
+
let base_evt = evt.as_web_event();
402
+
if let Some(clipboard_evt) = base_evt.dyn_ref::<web_sys::ClipboardEvent>() {
403
+
if let Some(data_transfer) = clipboard_evt.clipboard_data() {
404
+
if let Ok(text) = data_transfer.get_data("text/plain") {
405
+
tracing::info!("[PASTE] Got text: {} chars", text.len());
406
+
document.with_mut(|doc| {
407
+
// Delete selection if present
408
+
if let Some(sel) = doc.selection {
409
+
let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
410
+
doc.rope.remove(start..end);
411
+
doc.cursor.offset = start;
412
+
doc.selection = None;
413
+
}
414
+
415
+
// Insert pasted text
416
+
doc.rope.insert(doc.cursor.offset, &text);
417
+
doc.cursor.offset += text.chars().count();
418
+
});
419
+
}
420
}
421
+
} else {
422
+
tracing::warn!("[PASTE] Failed to cast to ClipboardEvent");
423
}
424
}
425
}
426
427
+
/// Handle cut events - browser copies selection, we delete it from rope
428
+
/// Selection is synced via onkeyup/onclick, so doc.selection should be current
429
+
fn handle_cut(_evt: Event<ClipboardData>, document: &mut Signal<EditorDocument>) {
430
+
tracing::info!("[CUT] handle_cut called");
431
+
432
+
document.with_mut(|doc| {
433
+
if let Some(sel) = doc.selection {
434
+
let (start, end) = (sel.anchor.min(sel.head), sel.anchor.max(sel.head));
435
+
if start != end {
436
+
tracing::info!("[CUT] Deleting selection {}..{}", start, end);
437
+
doc.rope.remove(start..end);
438
+
doc.cursor.offset = start;
439
+
doc.selection = None;
440
+
}
441
+
}
442
+
});
443
+
}
444
+
445
/// Handle keyboard events and update document state
446
fn handle_keydown(evt: Event<KeyboardData>, document: &mut Signal<EditorDocument>) {
447
use dioxus::prelude::keyboard_types::Key;
···
570
doc.rope.insert(doc.cursor.offset, " \n\u{200C}");
571
doc.cursor.offset += 3;
572
} else {
573
+
// Enter: paragraph break
0
0
0
0
0
574
doc.rope.insert(doc.cursor.offset, "\n\n");
575
doc.cursor.offset += 2;
0
0
0
0
0
576
}
577
}
578
···
682
let cursor_para_idx = new_paragraphs
683
.iter()
684
.position(|p| p.char_range.start <= cursor_offset && cursor_offset <= p.char_range.end);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
685
686
let mut cursor_para_updated = false;
687
+59
-19
crates/weaver-app/src/components/editor/render.rs
···
98
let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
99
let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
100
101
-
tracing::info!("[RENDER] Rendering {} paragraphs", paragraph_ranges.len());
102
for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
103
-
tracing::info!("[RENDER] Paragraph {}: char_range {:?}", idx, char_range);
104
// Extract paragraph source
105
let para_source = rope_slice_to_string(rope, char_range.clone());
106
let source_hash = hash_source(¶_source);
···
161
});
162
}
163
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
164
// Check if rope ends with trailing newlines (empty paragraph at end)
165
// If so, add an empty paragraph div for cursor positioning
166
let source = rope.to_string();
···
170
let doc_end_char = rope.len_chars();
171
let doc_end_byte = rope.len_bytes();
172
173
-
let empty_node_id = format!("n{}", node_id_offset);
174
-
let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
0
0
175
176
-
paragraphs.push(ParagraphRender {
177
-
byte_range: doc_end_byte..doc_end_byte,
178
-
char_range: doc_end_char..doc_end_char + 1, // range for the zero-width space
179
-
html: empty_html,
180
-
offset_map: vec![OffsetMapping {
181
-
byte_range: doc_end_byte..doc_end_byte,
182
-
char_range: doc_end_char..doc_end_char + 1,
183
-
node_id: empty_node_id,
184
-
char_offset_in_node: 0,
185
-
child_index: None,
186
-
utf16_len: 1, // zero-width space is 1 UTF-16 code unit
187
-
}],
188
-
source_hash: 0, // always render this paragraph
189
-
});
0
190
}
191
192
-
paragraphs
193
}
···
98
let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
99
let mut node_id_offset = 0; // Track total nodes used so far for unique IDs
100
0
101
for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() {
0
102
// Extract paragraph source
103
let para_source = rope_slice_to_string(rope, char_range.clone());
104
let source_hash = hash_source(¶_source);
···
159
});
160
}
161
162
+
// Insert gap paragraphs for whitespace between blocks
163
+
// This gives the cursor somewhere to land when positioned in newlines
164
+
let mut paragraphs_with_gaps = Vec::with_capacity(paragraphs.len() * 2);
165
+
let mut prev_end_char = 0usize;
166
+
let mut prev_end_byte = 0usize;
167
+
168
+
for para in paragraphs {
169
+
// Check for gap before this paragraph
170
+
if para.char_range.start > prev_end_char {
171
+
let gap_start_char = prev_end_char;
172
+
let gap_end_char = para.char_range.start;
173
+
let gap_start_byte = prev_end_byte;
174
+
let gap_end_byte = para.byte_range.start;
175
+
176
+
let gap_node_id = format!("n{}", node_id_offset);
177
+
node_id_offset += 1;
178
+
let gap_html = format!(r#"<span id="{}">{}</span>"#, gap_node_id, '\u{200B}');
179
+
180
+
paragraphs_with_gaps.push(ParagraphRender {
181
+
byte_range: gap_start_byte..gap_end_byte,
182
+
char_range: gap_start_char..gap_end_char,
183
+
html: gap_html,
184
+
offset_map: vec![OffsetMapping {
185
+
byte_range: gap_start_byte..gap_end_byte,
186
+
char_range: gap_start_char..gap_end_char,
187
+
node_id: gap_node_id,
188
+
char_offset_in_node: 0,
189
+
child_index: None,
190
+
utf16_len: 1, // zero-width space represents the gap
191
+
}],
192
+
source_hash: hash_source(&rope_slice_to_string(rope, gap_start_char..gap_end_char)),
193
+
});
194
+
}
195
+
196
+
prev_end_char = para.char_range.end;
197
+
prev_end_byte = para.byte_range.end;
198
+
paragraphs_with_gaps.push(para);
199
+
}
200
+
201
// Check if rope ends with trailing newlines (empty paragraph at end)
202
// If so, add an empty paragraph div for cursor positioning
203
let source = rope.to_string();
···
207
let doc_end_char = rope.len_chars();
208
let doc_end_byte = rope.len_bytes();
209
210
+
// Only add if there's actually a gap at the end
211
+
if doc_end_char > prev_end_char {
212
+
let empty_node_id = format!("n{}", node_id_offset);
213
+
let empty_html = format!(r#"<span id="{}">{}</span>"#, empty_node_id, '\u{200B}');
214
215
+
paragraphs_with_gaps.push(ParagraphRender {
216
+
byte_range: prev_end_byte..doc_end_byte,
217
+
char_range: prev_end_char..doc_end_char,
218
+
html: empty_html,
219
+
offset_map: vec![OffsetMapping {
220
+
byte_range: prev_end_byte..doc_end_byte,
221
+
char_range: prev_end_char..doc_end_char,
222
+
node_id: empty_node_id,
223
+
char_offset_in_node: 0,
224
+
child_index: None,
225
+
utf16_len: 1, // zero-width space is 1 UTF-16 code unit
226
+
}],
227
+
source_hash: 0, // always render this paragraph
228
+
});
229
+
}
230
}
231
232
+
paragraphs_with_gaps
233
}
+111
-49
crates/weaver-app/src/components/editor/writer.rs
···
288
return Ok(());
289
}
290
0
0
0
0
0
0
291
if next_offset > self.last_byte_offset {
292
self.emit_syntax(self.last_byte_offset..next_offset)?;
293
}
···
501
502
// Track byte and char ranges for code block content
503
let text_char_len = text.chars().count();
0
504
if let Some(ref mut code_byte_range) = self.code_buffer_byte_range {
505
// Extend existing ranges
506
code_byte_range.end = range.end;
···
512
self.code_buffer_byte_range = Some(range.clone());
513
self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len);
514
}
0
0
0
515
} else if !self.in_non_writing_block {
516
// Escape HTML and count chars in one pass
517
let char_start = self.last_char_offset;
···
533
}
534
}
535
Code(text) => {
536
-
// Emit opening backtick
537
-
if range.start < range.end {
538
-
let raw_text = &self.source[range.clone()];
539
-
if raw_text.starts_with('`') {
540
-
self.write("<span class=\"md-syntax-inline\">`</span>")?;
541
-
}
0
542
}
543
544
self.write("<code>")?;
545
546
// Track offset mapping for code content
547
-
let char_start = self.last_char_offset;
548
let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?;
549
-
let char_end = char_start + text_char_len;
550
551
// Record offset mapping (code content is visible)
552
-
self.record_mapping(range.clone(), char_start..char_end);
553
-
self.last_char_offset = char_end;
554
555
self.write("</code>")?;
556
557
-
// Emit closing backtick
558
-
if range.start < range.end {
559
-
let raw_text = &self.source[range];
560
-
if raw_text.ends_with('`') {
561
-
self.write("<span class=\"md-syntax-inline\">`</span>")?;
562
-
}
563
}
564
}
565
InlineMath(text) => {
566
-
// Emit opening $
567
-
if range.start < range.end {
568
-
let raw_text = &self.source[range.clone()];
569
-
if raw_text.starts_with('$') {
570
-
self.write("<span class=\"md-syntax-inline\">$</span>")?;
571
-
}
572
}
573
574
self.write(r#"<span class="math math-inline">"#)?;
0
575
escape_html(&mut self.writer, &text)?;
0
576
self.write("</span>")?;
577
578
-
// Emit closing $
579
-
if range.start < range.end {
580
-
let raw_text = &self.source[range];
581
-
if raw_text.ends_with('$') {
582
-
self.write("<span class=\"md-syntax-inline\">$</span>")?;
583
-
}
584
}
585
}
586
DisplayMath(text) => {
587
-
// Emit opening $$
588
-
if range.start < range.end {
589
-
let raw_text = &self.source[range.clone()];
590
-
if raw_text.starts_with("$$") {
591
-
self.write("<span class=\"md-syntax-inline\">$$</span>")?;
592
-
}
593
}
594
595
self.write(r#"<span class="math math-display">"#)?;
0
596
escape_html(&mut self.writer, &text)?;
0
597
self.write("</span>")?;
598
599
-
// Emit closing $$
600
-
if range.start < range.end {
601
-
let raw_text = &self.source[range];
602
-
if raw_text.ends_with("$$") {
603
-
self.write("<span class=\"md-syntax-inline\">$$</span>")?;
604
-
}
605
}
606
}
607
Html(html) | InlineHtml(html) => {
···
1018
Ok(())
1019
}
1020
Tag::CodeBlock(info) => {
0
0
0
1021
if !self.end_newline {
1022
self.write_newline()?;
1023
}
···
1027
1028
match info {
1029
CodeBlockKind::Fenced(info) => {
1030
-
// Emit opening ```language
1031
if range.start < range.end {
1032
-
let raw_text = &self.source[range];
1033
if let Some(fence_pos) = raw_text.find("```") {
1034
let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len());
1035
let syntax = &raw_text[fence_pos..fence_end];
0
0
1036
self.write("<span class=\"md-syntax-block\">")?;
1037
escape_html(&mut self.writer, syntax)?;
1038
self.write("</span>\n")?;
0
0
1039
}
1040
}
1041
···
1063
}
1064
}
1065
Tag::List(Some(1)) => {
0
0
1066
if self.end_newline {
1067
self.write("<ol>\n")
1068
} else {
···
1070
}
1071
}
1072
Tag::List(Some(start)) => {
0
0
1073
if self.end_newline {
1074
self.write("<ol start=\"")?;
1075
} else {
···
1079
self.write("\">\n")
1080
}
1081
Tag::List(None) => {
0
0
1082
if self.end_newline {
1083
self.write("<ul>\n")
1084
} else {
···
1098
// Begin node tracking
1099
self.begin_node(node_id);
1100
1101
-
// Emit list marker syntax inside the <li> tag
1102
if range.start < range.end {
1103
-
let raw_text = &self.source[range];
1104
1105
// Try to find the list marker (-, *, or digit.)
1106
let trimmed = raw_text.trim_start();
0
0
0
1107
if let Some(marker) = trimmed.chars().next() {
1108
if marker == '-' || marker == '*' {
1109
// Unordered list: extract "- " or "* "
···
1112
.map(|pos| pos + 1)
1113
.unwrap_or(1);
1114
let syntax = &trimmed[..marker_end.min(trimmed.len())];
0
0
1115
self.write("<span class=\"md-syntax-block\">")?;
1116
escape_html(&mut self.writer, syntax)?;
1117
self.write("</span>")?;
0
0
1118
} else if marker.is_ascii_digit() {
1119
// Ordered list: extract "1. " or similar
1120
if let Some(dot_pos) = trimmed.find('.') {
1121
let syntax_end = (dot_pos + 2).min(trimmed.len());
1122
let syntax = &trimmed[..syntax_end].trim_end();
0
0
1123
self.write("<span class=\"md-syntax-block\">")?;
1124
escape_html(&mut self.writer, syntax)?;
1125
self.write("</span>")?;
0
0
1126
}
1127
}
1128
}
···
1407
self.write("</code></pre>\n")?;
1408
}
1409
1410
-
// Emit closing ```
1411
if range.start < range.end {
1412
let raw_text = &self.source[range.clone()];
1413
if let Some(fence_line) = raw_text.lines().last() {
1414
-
if fence_line.trim() == "```" {
1415
-
self.write("<span class=\"md-syntax-block\">```</span>")?;
0
0
0
0
0
0
1416
}
1417
}
1418
}
1419
0
0
0
0
0
0
0
1420
Ok(())
1421
}
1422
-
TagEnd::List(true) => self.write("</ol>\n"),
1423
-
TagEnd::List(false) => self.write("</ul>\n"),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1424
TagEnd::Item => {
1425
self.end_node();
1426
self.write("</li>\n")
···
288
return Ok(());
289
}
290
291
+
// Skip gap emission if we're buffering code block content
292
+
// The code block handler manages its own syntax emission
293
+
if self.code_buffer.is_some() {
294
+
return Ok(());
295
+
}
296
+
297
if next_offset > self.last_byte_offset {
298
self.emit_syntax(self.last_byte_offset..next_offset)?;
299
}
···
507
508
// Track byte and char ranges for code block content
509
let text_char_len = text.chars().count();
510
+
let text_byte_len = text.len();
511
if let Some(ref mut code_byte_range) = self.code_buffer_byte_range {
512
// Extend existing ranges
513
code_byte_range.end = range.end;
···
519
self.code_buffer_byte_range = Some(range.clone());
520
self.code_buffer_char_range = Some(self.last_char_offset..self.last_char_offset + text_char_len);
521
}
522
+
// Update offsets so paragraph boundary is correct
523
+
self.last_char_offset += text_char_len;
524
+
self.last_byte_offset += text_byte_len;
525
} else if !self.in_non_writing_block {
526
// Escape HTML and count chars in one pass
527
let char_start = self.last_char_offset;
···
543
}
544
}
545
Code(text) => {
546
+
let char_start = self.last_char_offset;
547
+
let raw_text = &self.source[range.clone()];
548
+
549
+
// Emit opening backtick and track it
550
+
if raw_text.starts_with('`') {
551
+
self.write("<span class=\"md-syntax-inline\">`</span>")?;
552
+
self.last_char_offset += 1;
553
}
554
555
self.write("<code>")?;
556
557
// Track offset mapping for code content
558
+
let content_char_start = self.last_char_offset;
559
let text_char_len = escape_html_body_text_with_char_count(&mut self.writer, &text)?;
560
+
let content_char_end = content_char_start + text_char_len;
561
562
// Record offset mapping (code content is visible)
563
+
self.record_mapping(range.clone(), content_char_start..content_char_end);
564
+
self.last_char_offset = content_char_end;
565
566
self.write("</code>")?;
567
568
+
// Emit closing backtick and track it
569
+
if raw_text.ends_with('`') {
570
+
self.write("<span class=\"md-syntax-inline\">`</span>")?;
571
+
self.last_char_offset += 1;
0
0
572
}
573
}
574
InlineMath(text) => {
575
+
let raw_text = &self.source[range.clone()];
576
+
577
+
// Emit opening $ and track it
578
+
if raw_text.starts_with('$') {
579
+
self.write("<span class=\"md-syntax-inline\">$</span>")?;
580
+
self.last_char_offset += 1;
581
}
582
583
self.write(r#"<span class="math math-inline">"#)?;
584
+
let text_char_len = text.chars().count();
585
escape_html(&mut self.writer, &text)?;
586
+
self.last_char_offset += text_char_len;
587
self.write("</span>")?;
588
589
+
// Emit closing $ and track it
590
+
if raw_text.ends_with('$') {
591
+
self.write("<span class=\"md-syntax-inline\">$</span>")?;
592
+
self.last_char_offset += 1;
0
0
593
}
594
}
595
DisplayMath(text) => {
596
+
let raw_text = &self.source[range.clone()];
597
+
598
+
// Emit opening $$ and track it
599
+
if raw_text.starts_with("$$") {
600
+
self.write("<span class=\"md-syntax-inline\">$$</span>")?;
601
+
self.last_char_offset += 2;
602
}
603
604
self.write(r#"<span class="math math-display">"#)?;
605
+
let text_char_len = text.chars().count();
606
escape_html(&mut self.writer, &text)?;
607
+
self.last_char_offset += text_char_len;
608
self.write("</span>")?;
609
610
+
// Emit closing $$ and track it
611
+
if raw_text.ends_with("$$") {
612
+
self.write("<span class=\"md-syntax-inline\">$$</span>")?;
613
+
self.last_char_offset += 2;
0
0
614
}
615
}
616
Html(html) | InlineHtml(html) => {
···
1027
Ok(())
1028
}
1029
Tag::CodeBlock(info) => {
1030
+
// Track code block as paragraph-level block
1031
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
1032
+
1033
if !self.end_newline {
1034
self.write_newline()?;
1035
}
···
1039
1040
match info {
1041
CodeBlockKind::Fenced(info) => {
1042
+
// Emit opening ```language and track both char and byte offsets
1043
if range.start < range.end {
1044
+
let raw_text = &self.source[range.clone()];
1045
if let Some(fence_pos) = raw_text.find("```") {
1046
let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len());
1047
let syntax = &raw_text[fence_pos..fence_end];
1048
+
let syntax_char_len = syntax.chars().count() + 1; // +1 for newline
1049
+
let syntax_byte_len = syntax.len() + 1; // +1 for newline
1050
self.write("<span class=\"md-syntax-block\">")?;
1051
escape_html(&mut self.writer, syntax)?;
1052
self.write("</span>\n")?;
1053
+
self.last_char_offset += syntax_char_len;
1054
+
self.last_byte_offset = range.start + fence_pos + syntax_byte_len;
1055
}
1056
}
1057
···
1079
}
1080
}
1081
Tag::List(Some(1)) => {
1082
+
// Track list as paragraph-level block
1083
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
1084
if self.end_newline {
1085
self.write("<ol>\n")
1086
} else {
···
1088
}
1089
}
1090
Tag::List(Some(start)) => {
1091
+
// Track list as paragraph-level block
1092
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
1093
if self.end_newline {
1094
self.write("<ol start=\"")?;
1095
} else {
···
1099
self.write("\">\n")
1100
}
1101
Tag::List(None) => {
1102
+
// Track list as paragraph-level block
1103
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
1104
if self.end_newline {
1105
self.write("<ul>\n")
1106
} else {
···
1120
// Begin node tracking
1121
self.begin_node(node_id);
1122
1123
+
// Emit list marker syntax inside the <li> tag and track both offsets
1124
if range.start < range.end {
1125
+
let raw_text = &self.source[range.clone()];
1126
1127
// Try to find the list marker (-, *, or digit.)
1128
let trimmed = raw_text.trim_start();
1129
+
let leading_ws_bytes = raw_text.len() - trimmed.len();
1130
+
let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count();
1131
+
1132
if let Some(marker) = trimmed.chars().next() {
1133
if marker == '-' || marker == '*' {
1134
// Unordered list: extract "- " or "* "
···
1137
.map(|pos| pos + 1)
1138
.unwrap_or(1);
1139
let syntax = &trimmed[..marker_end.min(trimmed.len())];
1140
+
let syntax_char_len = leading_ws_chars + syntax.chars().count();
1141
+
let syntax_byte_len = leading_ws_bytes + syntax.len();
1142
self.write("<span class=\"md-syntax-block\">")?;
1143
escape_html(&mut self.writer, syntax)?;
1144
self.write("</span>")?;
1145
+
self.last_char_offset += syntax_char_len;
1146
+
self.last_byte_offset = range.start + syntax_byte_len;
1147
} else if marker.is_ascii_digit() {
1148
// Ordered list: extract "1. " or similar
1149
if let Some(dot_pos) = trimmed.find('.') {
1150
let syntax_end = (dot_pos + 2).min(trimmed.len());
1151
let syntax = &trimmed[..syntax_end].trim_end();
1152
+
let syntax_char_len = leading_ws_chars + syntax.chars().count();
1153
+
let syntax_byte_len = leading_ws_bytes + syntax.len();
1154
self.write("<span class=\"md-syntax-block\">")?;
1155
escape_html(&mut self.writer, syntax)?;
1156
self.write("</span>")?;
1157
+
self.last_char_offset += syntax_char_len;
1158
+
self.last_byte_offset = range.start + syntax_byte_len;
1159
}
1160
}
1161
}
···
1440
self.write("</code></pre>\n")?;
1441
}
1442
1443
+
// Emit closing ``` (emit_gap_before is skipped while buffering)
1444
if range.start < range.end {
1445
let raw_text = &self.source[range.clone()];
1446
if let Some(fence_line) = raw_text.lines().last() {
1447
+
if fence_line.trim().starts_with("```") {
1448
+
let fence = fence_line.trim();
1449
+
let fence_char_len = fence.chars().count();
1450
+
self.write("<span class=\"md-syntax-block\">")?;
1451
+
escape_html(&mut self.writer, fence)?;
1452
+
self.write("</span>")?;
1453
+
self.last_char_offset += fence_char_len;
1454
+
self.last_byte_offset += fence.len();
1455
}
1456
}
1457
}
1458
1459
+
// Record code block end for paragraph boundary tracking
1460
+
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1461
+
let byte_range = byte_start..self.last_byte_offset;
1462
+
let char_range = char_start..self.last_char_offset;
1463
+
self.paragraph_ranges.push((byte_range, char_range));
1464
+
}
1465
+
1466
Ok(())
1467
}
1468
+
TagEnd::List(true) => {
1469
+
// Record list end for paragraph boundary tracking
1470
+
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1471
+
let byte_range = byte_start..self.last_byte_offset;
1472
+
let char_range = char_start..self.last_char_offset;
1473
+
self.paragraph_ranges.push((byte_range, char_range));
1474
+
}
1475
+
self.write("</ol>\n")
1476
+
}
1477
+
TagEnd::List(false) => {
1478
+
// Record list end for paragraph boundary tracking
1479
+
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1480
+
let byte_range = byte_start..self.last_byte_offset;
1481
+
let char_range = char_start..self.last_char_offset;
1482
+
self.paragraph_ranges.push((byte_range, char_range));
1483
+
}
1484
+
self.write("</ul>\n")
1485
+
}
1486
TagEnd::Item => {
1487
self.end_node();
1488
self.write("</li>\n")