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