···496 }
497498 if needs_update {
499- // TESTING: Force innerHTML update to measure timing cost
500- // TODO: Remove this flag after benchmarking
501- const FORCE_INNERHTML_UPDATE: bool = true;
502503 // For cursor paragraph: only update if syntax/formatting changed
504 // This prevents destroying browser selection during fast typing
···549 // Update hash - browser native editing has the correct content
550 let _ = existing_elem.set_attribute("data-hash", &new_hash);
551 } else {
00000000000552 // Timing instrumentation for innerHTML update cost
553 let start = web_sys::window()
554 .and_then(|w| w.performance())
···496 }
497498 if needs_update {
499+ use super::FORCE_INNERHTML_UPDATE;
00500501 // For cursor paragraph: only update if syntax/formatting changed
502 // This prevents destroying browser selection during fast typing
···547 // Update hash - browser native editing has the correct content
548 let _ = existing_elem.set_attribute("data-hash", &new_hash);
549 } else {
550+ // Log old innerHTML before replacement to see what browser did
551+ if tracing::enabled!(tracing::Level::TRACE) {
552+ let old_inner = existing_elem.inner_html();
553+ tracing::trace!(
554+ para_id = %para_id,
555+ old_inner = %old_inner.escape_debug(),
556+ new_html = %new_para.html.escape_debug(),
557+ "update_paragraph_dom: replacing innerHTML"
558+ );
559+ }
560+561 // Timing instrumentation for innerHTML update cost
562 let start = web_sys::window()
563 .and_then(|w| w.performance())
+9
crates/weaver-app/src/components/editor/mod.rs
···32#[cfg(test)]
33mod tests;
3400000000035// Main component
36pub use component::MarkdownEditor;
37
···32#[cfg(test)]
33mod tests;
3435+/// When true, always update innerHTML even for cursor paragraph during typing.
36+/// This ensures syntax/formatting changes are immediately visible, but requires
37+/// using `Handled` (preventDefault) for InsertText to avoid double-insertion
38+/// from browser's default action racing with our innerHTML update.
39+///
40+/// TODO: Replace with granular detection of syntax/formatting changes to allow
41+/// PassThrough optimization when only text content changes.
42+pub(crate) const FORCE_INNERHTML_UPDATE: bool = true;
43+44// Main component
45pub use component::MarkdownEditor;
46
···16impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
17 EditorWriter<'a, I, E, R>
18{
000000000000019 pub(crate) fn start_tag(
20 &mut self,
21 tag: Tag<'_>,
···128 // HTML blocks get their own paragraph to try and corral them better
129 Tag::HtmlBlock => {
130 // Record paragraph start for boundary tracking
131- // BUT skip if inside a list - list owns the paragraph boundary
132- if self.list_depth == 0 {
133 self.current_paragraph_start =
134 Some((self.last_byte_offset, self.last_char_offset));
135 }
···170 self.emit_wrapper_start()?;
171172 // Record paragraph start for boundary tracking
173- // BUT skip if inside a list - list owns the paragraph boundary
174- if self.list_depth == 0 {
175 self.current_paragraph_start =
176 Some((self.last_byte_offset, self.last_char_offset));
177 }
178179 let node_id = self.gen_node_id();
0000180 if self.end_newline {
181- write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
0000182 } else {
183- write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
0000184 }
185 self.begin_node(node_id.clone());
186···244 // Generate node ID for offset tracking
245 let node_id = self.gen_node_id();
246000247 self.write("<")?;
248 write!(&mut self.writer, "{}", level)?;
249···267 }
268 self.write("\"")?;
269 }
00000000270 for (attr, value) in attrs {
271 self.write(" ")?;
272 escape_html(&mut self.writer, &attr)?;
···868 Ok(())
869 }
870 Tag::FootnoteDefinition(name) => {
871- // Emit the [^name]: prefix as a hideable syntax span
872- // The source should have "[^name]: " at the start
873- let prefix = format!("[^{}]: ", name);
874- let char_start = self.last_char_offset;
875- let prefix_char_len = prefix.chars().count();
876- let char_end = char_start + prefix_char_len;
877- let syn_id = self.gen_syn_id();
878879 if !self.end_newline {
880 self.write("\n")?;
881 }
88200000883 write!(
884 &mut self.writer,
885- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
886- syn_id, char_start, char_end
0000000000000000000000000000000000000000000887 )?;
888- escape_html(&mut self.writer, &prefix)?;
889 self.write("</span>")?;
890891- // Track this span for linking with the footnote reference
892- let def_span_index = self.syntax_spans.len();
893- self.syntax_spans.push(SyntaxSpanInfo {
894- syn_id,
895- char_range: char_start..char_end,
896- syntax_type: SyntaxType::Block,
897- formatted_range: None, // Set at FootnoteDefinition end
898- });
899-900- // Store the definition info for linking at end
901- self.current_footnote_def = Some((name.to_string(), def_span_index, char_start));
902903- // Record offset mapping for the syntax span
904 self.record_mapping(
905- range.start..range.start + prefix.len(),
906 char_start..char_end,
907 );
908909 // Update tracking for the prefix
910 self.last_char_offset = char_end;
911- self.last_byte_offset = range.start + prefix.len();
912-913- // Emit the definition container
914- write!(
915- &mut self.writer,
916- "<div class=\"footnote-definition\" id=\"fn-{}\">",
917- name
918- )?;
919-920- // Get/create footnote number for the label
921- let len = self.numbers.len() + 1;
922- let number = *self.numbers.entry(name.to_string()).or_insert(len);
923- write!(
924- &mut self.writer,
925- "<sup class=\"footnote-definition-label\">{}</sup>",
926- number
927- )?;
928929 Ok(())
930 }
···946 let result = match tag {
947 TagEnd::HtmlBlock => {
948 // Capture paragraph boundary info BEFORE writing closing HTML
949- // Skip if inside a list - list owns the paragraph boundary
950- let para_boundary = if self.list_depth == 0 {
951 self.current_paragraph_start
952 .take()
953 .map(|(byte_start, char_start)| {
···972 }
973 TagEnd::Paragraph(_) => {
974 // Capture paragraph boundary info BEFORE writing closing HTML
975- // Skip if inside a list - list owns the paragraph boundary
976- let para_boundary = if self.list_depth == 0 {
977 self.current_paragraph_start
978 .take()
979 .map(|(byte_start, char_start)| {
···1418 Ok(())
1419 }
1420 TagEnd::FootnoteDefinition => {
001421 self.write("</div>\n")?;
14221423- // Link the footnote definition span with its reference span
1424- if let Some((name, def_span_index, _def_char_start)) =
1425- self.current_footnote_def.take()
1426- {
1427- let def_char_end = self.last_char_offset;
14281429- // Look up the reference span
1430- if let Some(&(ref_span_index, ref_char_start)) =
1431- self.footnote_ref_spans.get(&name)
1432- {
1433- // Create formatted_range spanning from ref start to def end
1434- let formatted_range = ref_char_start..def_char_end;
1435-1436- // Update both spans with the same formatted_range
1437- // so they show/hide together based on cursor proximity
1438- if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) {
1439- ref_span.formatted_range = Some(formatted_range.clone());
1440- }
1441- if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) {
1442- def_span.formatted_range = Some(formatted_range);
1443- }
1444- }
1445 }
14461447 Ok(())
···16impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
17 EditorWriter<'a, I, E, R>
18{
19+ /// Detect text direction by scanning source from a byte offset.
20+ /// Looks for the first strong directional character.
21+ /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found.
22+ fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> {
23+ if start_byte >= self.source.len() {
24+ return None;
25+ }
26+27+ // Scan from start_byte through the source looking for first strong directional char
28+ let text = &self.source[start_byte..];
29+ weaver_renderer::utils::detect_text_direction(text)
30+ }
31+32 pub(crate) fn start_tag(
33 &mut self,
34 tag: Tag<'_>,
···141 // HTML blocks get their own paragraph to try and corral them better
142 Tag::HtmlBlock => {
143 // Record paragraph start for boundary tracking
144+ // Skip if inside a list or footnote def - they own their paragraph boundary
145+ if self.list_depth == 0 && !self.in_footnote_def {
146 self.current_paragraph_start =
147 Some((self.last_byte_offset, self.last_char_offset));
148 }
···183 self.emit_wrapper_start()?;
184185 // Record paragraph start for boundary tracking
186+ // Skip if inside a list or footnote def - they own their paragraph boundary
187+ if self.list_depth == 0 && !self.in_footnote_def {
188 self.current_paragraph_start =
189 Some((self.last_byte_offset, self.last_char_offset));
190 }
191192 let node_id = self.gen_node_id();
193+194+ // Detect text direction for this paragraph
195+ let dir = self.detect_paragraph_direction(self.last_byte_offset);
196+197 if self.end_newline {
198+ if let Some(dir_value) = dir {
199+ write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
200+ } else {
201+ write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
202+ }
203 } else {
204+ if let Some(dir_value) = dir {
205+ write!(&mut self.writer, "\n<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
206+ } else {
207+ write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
208+ }
209 }
210 self.begin_node(node_id.clone());
211···269 // Generate node ID for offset tracking
270 let node_id = self.gen_node_id();
271272+ // Detect text direction for this heading
273+ let dir = self.detect_paragraph_direction(self.last_byte_offset);
274+275 self.write("<")?;
276 write!(&mut self.writer, "{}", level)?;
277···295 }
296 self.write("\"")?;
297 }
298+299+ // Add dir attribute if text direction was detected
300+ if let Some(dir_value) = dir {
301+ self.write(" dir=\"")?;
302+ self.write(dir_value)?;
303+ self.write("\"")?;
304+ }
305+306 for (attr, value) in attrs {
307 self.write(" ")?;
308 escape_html(&mut self.writer, &attr)?;
···904 Ok(())
905 }
906 Tag::FootnoteDefinition(name) => {
907+ // Track as paragraph-level block for incremental rendering
908+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
909+ // Suppress inner paragraph boundaries (footnote def owns its paragraph)
910+ self.in_footnote_def = true;
000911912 if !self.end_newline {
913 self.write("\n")?;
914 }
915916+ // Generate node ID for cursor tracking
917+ let node_id = self.gen_node_id();
918+919+ // Emit wrapper div with NEW class (not footnote-definition which has order:9999)
920+ // This keeps footnotes in-place instead of reordering to bottom
921 write!(
922 &mut self.writer,
923+ "<div class=\"footnote-def-editor\" data-node-id=\"{}\">",
924+ node_id
925+ )?;
926+927+ // Begin node tracking BEFORE emitting prefix
928+ self.begin_node(node_id.clone());
929+930+ // Map the start position (before any content)
931+ let fn_start_char = self.last_char_offset;
932+ let mapping = OffsetMapping {
933+ byte_range: range.start..range.start,
934+ char_range: fn_start_char..fn_start_char,
935+ node_id,
936+ char_offset_in_node: 0,
937+ child_index: Some(0),
938+ utf16_len: 0,
939+ };
940+ self.offset_maps.push(mapping);
941+942+ // Extract ACTUAL prefix from source (not constructed string)
943+ // This ensures byte offsets match reality
944+ let raw_text = &self.source[range.clone()];
945+ let prefix_end = raw_text
946+ .find("]:")
947+ .map(|p| {
948+ // Include ]: and any single trailing space
949+ let after_colon = p + 2;
950+ if raw_text.get(after_colon..after_colon + 1) == Some(" ") {
951+ after_colon + 1
952+ } else {
953+ after_colon
954+ }
955+ })
956+ .unwrap_or(0);
957+ let prefix = &raw_text[..prefix_end];
958+ let prefix_byte_len = prefix.len();
959+ let prefix_char_len = prefix.chars().count();
960+961+ let char_start = self.last_char_offset;
962+ let char_end = char_start + prefix_char_len;
963+964+ write!(
965+ &mut self.writer,
966+ "<span class=\"footnote-def-syntax\" data-char-start=\"{}\" data-char-end=\"{}\">",
967+ char_start, char_end
968 )?;
969+ escape_html(&mut self.writer, prefix)?;
970 self.write("</span>")?;
971972+ // Store the definition info (no longer tracking syntax spans for hide/show)
973+ self.current_footnote_def = Some((name.to_string(), 0, char_start));
000000000974975+ // Record offset mapping for the prefix
976 self.record_mapping(
977+ range.start..range.start + prefix_byte_len,
978 char_start..char_end,
979 );
980981 // Update tracking for the prefix
982 self.last_char_offset = char_end;
983+ self.last_byte_offset = range.start + prefix_byte_len;
0000000000000000984985 Ok(())
986 }
···1002 let result = match tag {
1003 TagEnd::HtmlBlock => {
1004 // Capture paragraph boundary info BEFORE writing closing HTML
1005+ // Skip if inside a list or footnote def - they own their paragraph boundary
1006+ let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
1007 self.current_paragraph_start
1008 .take()
1009 .map(|(byte_start, char_start)| {
···1028 }
1029 TagEnd::Paragraph(_) => {
1030 // Capture paragraph boundary info BEFORE writing closing HTML
1031+ // Skip if inside a list or footnote def - they own their paragraph boundary
1032+ let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
1033 self.current_paragraph_start
1034 .take()
1035 .map(|(byte_start, char_start)| {
···1474 Ok(())
1475 }
1476 TagEnd::FootnoteDefinition => {
1477+ // End node tracking (inner paragraphs may have already cleared it)
1478+ self.end_node();
1479 self.write("</div>\n")?;
14801481+ // Clear footnote tracking
1482+ self.current_footnote_def.take();
1483+ self.in_footnote_def = false;
0014841485+ // Finalize paragraph boundary for incremental rendering
1486+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1487+ let byte_range = byte_start..self.last_byte_offset;
1488+ let char_range = char_start..self.last_char_offset;
1489+ self.finalize_paragraph(byte_range, char_range);
000000000001490 }
14911492 Ok(())
···208 ///
209 /// Compares edit_heads to draft_titles to find drafts where the current
210 /// head doesn't match the head used for title extraction.
211- pub async fn get_stale_draft_titles(&self, limit: i64) -> Result<Vec<StaleDraftRow>, IndexError> {
000212 // Join drafts -> edit_heads (for current head) -> draft_titles (to check staleness)
213 // edit_heads uses resource_type='draft' and resource_did/resource_rkey to link
214 let query = r#"
215 SELECT
216- d.did,
217- d.rkey,
218- h.head_did,
219- h.head_rkey,
220- h.head_cid,
221- h.root_did,
222- h.root_rkey,
223- h.root_cid
224 FROM drafts d FINAL
225 INNER JOIN edit_heads h FINAL
226 ON h.resource_did = d.did
···208 ///
209 /// Compares edit_heads to draft_titles to find drafts where the current
210 /// head doesn't match the head used for title extraction.
211+ pub async fn get_stale_draft_titles(
212+ &self,
213+ limit: i64,
214+ ) -> Result<Vec<StaleDraftRow>, IndexError> {
215 // Join drafts -> edit_heads (for current head) -> draft_titles (to check staleness)
216 // edit_heads uses resource_type='draft' and resource_did/resource_rkey to link
217 let query = r#"
218 SELECT
219+ d.did as did,
220+ d.rkey as rkey,
221+ h.head_did as head_did,
222+ h.head_rkey as head_rkey,
223+ h.head_cid as head_cid,
224+ h.root_did as root_did,
225+ h.root_rkey as root_rkey,
226+ h.root_cid as root_cid
227 FROM drafts d FINAL
228 INNER JOIN edit_heads h FINAL
229 ON h.resource_did = d.did
···2source: crates/weaver-renderer/src/atproto/tests.rs
3expression: output
4---
5-<p>Here is some text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
···2source: crates/weaver-renderer/src/atproto/tests.rs
3expression: output
4---
5+<p dir="ltr">Here is some text<label for="sn-1" class="sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
···2source: crates/weaver-renderer/src/atproto/tests.rs
3expression: output
4---
5-<p>This is a paragraph.</p>
6-<p>This is another paragraph.</p>
···2source: crates/weaver-renderer/src/atproto/tests.rs
3expression: output
4---
5+<p dir="ltr">This is a paragraph.</p>
6+<p dir="ltr">This is another paragraph.</p>
···2source: crates/weaver-renderer/src/static_site/tests.rs
3expression: output
4---
5-<p>Here is some text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
···2source: crates/weaver-renderer/src/static_site/tests.rs
3expression: output
4---
5+<p dir="ltr">Here is some text<label for="sn-1" class="sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
···2source: crates/weaver-renderer/src/static_site/tests.rs
3expression: output
4---
5-<p>This is a paragraph.</p>
6-<p>This is another paragraph.</p>
···2source: crates/weaver-renderer/src/static_site/tests.rs
3expression: output
4---
5+<p dir="ltr">This is a paragraph.</p>
6+<p dir="ltr">This is another paragraph.</p>
···2223{.aside}
24> **On Platform Decay**
25->
26> Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.
2728But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.
···2223{.aside}
24> **On Platform Decay**
025> Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.
2627But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.