···496496 }
497497498498 if needs_update {
499499- // TESTING: Force innerHTML update to measure timing cost
500500- // TODO: Remove this flag after benchmarking
501501- const FORCE_INNERHTML_UPDATE: bool = true;
499499+ use super::FORCE_INNERHTML_UPDATE;
502500503501 // For cursor paragraph: only update if syntax/formatting changed
504502 // This prevents destroying browser selection during fast typing
···549547 // Update hash - browser native editing has the correct content
550548 let _ = existing_elem.set_attribute("data-hash", &new_hash);
551549 } else {
550550+ // Log old innerHTML before replacement to see what browser did
551551+ if tracing::enabled!(tracing::Level::TRACE) {
552552+ let old_inner = existing_elem.inner_html();
553553+ tracing::trace!(
554554+ para_id = %para_id,
555555+ old_inner = %old_inner.escape_debug(),
556556+ new_html = %new_para.html.escape_debug(),
557557+ "update_paragraph_dom: replacing innerHTML"
558558+ );
559559+ }
560560+552561 // Timing instrumentation for innerHTML update cost
553562 let start = web_sys::window()
554563 .and_then(|w| w.performance())
+9
crates/weaver-app/src/components/editor/mod.rs
···3232#[cfg(test)]
3333mod tests;
34343535+/// When true, always update innerHTML even for cursor paragraph during typing.
3636+/// This ensures syntax/formatting changes are immediately visible, but requires
3737+/// using `Handled` (preventDefault) for InsertText to avoid double-insertion
3838+/// from browser's default action racing with our innerHTML update.
3939+///
4040+/// TODO: Replace with granular detection of syntax/formatting changes to allow
4141+/// PassThrough optimization when only text content changes.
4242+pub(crate) const FORCE_INNERHTML_UPDATE: bool = true;
4343+3544// Main component
3645pub use component::MarkdownEditor;
3746
···1616impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
1717 EditorWriter<'a, I, E, R>
1818{
1919+ /// Detect text direction by scanning source from a byte offset.
2020+ /// Looks for the first strong directional character.
2121+ /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found.
2222+ fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> {
2323+ if start_byte >= self.source.len() {
2424+ return None;
2525+ }
2626+2727+ // Scan from start_byte through the source looking for first strong directional char
2828+ let text = &self.source[start_byte..];
2929+ weaver_renderer::utils::detect_text_direction(text)
3030+ }
3131+1932 pub(crate) fn start_tag(
2033 &mut self,
2134 tag: Tag<'_>,
···128141 // HTML blocks get their own paragraph to try and corral them better
129142 Tag::HtmlBlock => {
130143 // Record paragraph start for boundary tracking
131131- // BUT skip if inside a list - list owns the paragraph boundary
132132- if self.list_depth == 0 {
144144+ // Skip if inside a list or footnote def - they own their paragraph boundary
145145+ if self.list_depth == 0 && !self.in_footnote_def {
133146 self.current_paragraph_start =
134147 Some((self.last_byte_offset, self.last_char_offset));
135148 }
···170183 self.emit_wrapper_start()?;
171184172185 // Record paragraph start for boundary tracking
173173- // BUT skip if inside a list - list owns the paragraph boundary
174174- if self.list_depth == 0 {
186186+ // Skip if inside a list or footnote def - they own their paragraph boundary
187187+ if self.list_depth == 0 && !self.in_footnote_def {
175188 self.current_paragraph_start =
176189 Some((self.last_byte_offset, self.last_char_offset));
177190 }
178191179192 let node_id = self.gen_node_id();
193193+194194+ // Detect text direction for this paragraph
195195+ let dir = self.detect_paragraph_direction(self.last_byte_offset);
196196+180197 if self.end_newline {
181181- write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
198198+ if let Some(dir_value) = dir {
199199+ write!(&mut self.writer, "<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
200200+ } else {
201201+ write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
202202+ }
182203 } else {
183183- write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
204204+ if let Some(dir_value) = dir {
205205+ write!(&mut self.writer, "\n<p id=\"{}\" dir=\"{}\">", node_id, dir_value)?;
206206+ } else {
207207+ write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
208208+ }
184209 }
185210 self.begin_node(node_id.clone());
186211···244269 // Generate node ID for offset tracking
245270 let node_id = self.gen_node_id();
246271272272+ // Detect text direction for this heading
273273+ let dir = self.detect_paragraph_direction(self.last_byte_offset);
274274+247275 self.write("<")?;
248276 write!(&mut self.writer, "{}", level)?;
249277···267295 }
268296 self.write("\"")?;
269297 }
298298+299299+ // Add dir attribute if text direction was detected
300300+ if let Some(dir_value) = dir {
301301+ self.write(" dir=\"")?;
302302+ self.write(dir_value)?;
303303+ self.write("\"")?;
304304+ }
305305+270306 for (attr, value) in attrs {
271307 self.write(" ")?;
272308 escape_html(&mut self.writer, &attr)?;
···868904 Ok(())
869905 }
870906 Tag::FootnoteDefinition(name) => {
871871- // Emit the [^name]: prefix as a hideable syntax span
872872- // The source should have "[^name]: " at the start
873873- let prefix = format!("[^{}]: ", name);
874874- let char_start = self.last_char_offset;
875875- let prefix_char_len = prefix.chars().count();
876876- let char_end = char_start + prefix_char_len;
877877- let syn_id = self.gen_syn_id();
907907+ // Track as paragraph-level block for incremental rendering
908908+ self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
909909+ // Suppress inner paragraph boundaries (footnote def owns its paragraph)
910910+ self.in_footnote_def = true;
878911879912 if !self.end_newline {
880913 self.write("\n")?;
881914 }
882915916916+ // Generate node ID for cursor tracking
917917+ let node_id = self.gen_node_id();
918918+919919+ // Emit wrapper div with NEW class (not footnote-definition which has order:9999)
920920+ // This keeps footnotes in-place instead of reordering to bottom
883921 write!(
884922 &mut self.writer,
885885- "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
886886- syn_id, char_start, char_end
923923+ "<div class=\"footnote-def-editor\" data-node-id=\"{}\">",
924924+ node_id
925925+ )?;
926926+927927+ // Begin node tracking BEFORE emitting prefix
928928+ self.begin_node(node_id.clone());
929929+930930+ // Map the start position (before any content)
931931+ let fn_start_char = self.last_char_offset;
932932+ let mapping = OffsetMapping {
933933+ byte_range: range.start..range.start,
934934+ char_range: fn_start_char..fn_start_char,
935935+ node_id,
936936+ char_offset_in_node: 0,
937937+ child_index: Some(0),
938938+ utf16_len: 0,
939939+ };
940940+ self.offset_maps.push(mapping);
941941+942942+ // Extract ACTUAL prefix from source (not constructed string)
943943+ // This ensures byte offsets match reality
944944+ let raw_text = &self.source[range.clone()];
945945+ let prefix_end = raw_text
946946+ .find("]:")
947947+ .map(|p| {
948948+ // Include ]: and any single trailing space
949949+ let after_colon = p + 2;
950950+ if raw_text.get(after_colon..after_colon + 1) == Some(" ") {
951951+ after_colon + 1
952952+ } else {
953953+ after_colon
954954+ }
955955+ })
956956+ .unwrap_or(0);
957957+ let prefix = &raw_text[..prefix_end];
958958+ let prefix_byte_len = prefix.len();
959959+ let prefix_char_len = prefix.chars().count();
960960+961961+ let char_start = self.last_char_offset;
962962+ let char_end = char_start + prefix_char_len;
963963+964964+ write!(
965965+ &mut self.writer,
966966+ "<span class=\"footnote-def-syntax\" data-char-start=\"{}\" data-char-end=\"{}\">",
967967+ char_start, char_end
887968 )?;
888888- escape_html(&mut self.writer, &prefix)?;
969969+ escape_html(&mut self.writer, prefix)?;
889970 self.write("</span>")?;
890971891891- // Track this span for linking with the footnote reference
892892- let def_span_index = self.syntax_spans.len();
893893- self.syntax_spans.push(SyntaxSpanInfo {
894894- syn_id,
895895- char_range: char_start..char_end,
896896- syntax_type: SyntaxType::Block,
897897- formatted_range: None, // Set at FootnoteDefinition end
898898- });
899899-900900- // Store the definition info for linking at end
901901- self.current_footnote_def = Some((name.to_string(), def_span_index, char_start));
972972+ // Store the definition info (no longer tracking syntax spans for hide/show)
973973+ self.current_footnote_def = Some((name.to_string(), 0, char_start));
902974903903- // Record offset mapping for the syntax span
975975+ // Record offset mapping for the prefix
904976 self.record_mapping(
905905- range.start..range.start + prefix.len(),
977977+ range.start..range.start + prefix_byte_len,
906978 char_start..char_end,
907979 );
908980909981 // Update tracking for the prefix
910982 self.last_char_offset = char_end;
911911- self.last_byte_offset = range.start + prefix.len();
912912-913913- // Emit the definition container
914914- write!(
915915- &mut self.writer,
916916- "<div class=\"footnote-definition\" id=\"fn-{}\">",
917917- name
918918- )?;
919919-920920- // Get/create footnote number for the label
921921- let len = self.numbers.len() + 1;
922922- let number = *self.numbers.entry(name.to_string()).or_insert(len);
923923- write!(
924924- &mut self.writer,
925925- "<sup class=\"footnote-definition-label\">{}</sup>",
926926- number
927927- )?;
983983+ self.last_byte_offset = range.start + prefix_byte_len;
928984929985 Ok(())
930986 }
···9461002 let result = match tag {
9471003 TagEnd::HtmlBlock => {
9481004 // Capture paragraph boundary info BEFORE writing closing HTML
949949- // Skip if inside a list - list owns the paragraph boundary
950950- let para_boundary = if self.list_depth == 0 {
10051005+ // Skip if inside a list or footnote def - they own their paragraph boundary
10061006+ let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
9511007 self.current_paragraph_start
9521008 .take()
9531009 .map(|(byte_start, char_start)| {
···9721028 }
9731029 TagEnd::Paragraph(_) => {
9741030 // Capture paragraph boundary info BEFORE writing closing HTML
975975- // Skip if inside a list - list owns the paragraph boundary
976976- let para_boundary = if self.list_depth == 0 {
10311031+ // Skip if inside a list or footnote def - they own their paragraph boundary
10321032+ let para_boundary = if self.list_depth == 0 && !self.in_footnote_def {
9771033 self.current_paragraph_start
9781034 .take()
9791035 .map(|(byte_start, char_start)| {
···14181474 Ok(())
14191475 }
14201476 TagEnd::FootnoteDefinition => {
14771477+ // End node tracking (inner paragraphs may have already cleared it)
14781478+ self.end_node();
14211479 self.write("</div>\n")?;
1422148014231423- // Link the footnote definition span with its reference span
14241424- if let Some((name, def_span_index, _def_char_start)) =
14251425- self.current_footnote_def.take()
14261426- {
14271427- let def_char_end = self.last_char_offset;
14811481+ // Clear footnote tracking
14821482+ self.current_footnote_def.take();
14831483+ self.in_footnote_def = false;
1428148414291429- // Look up the reference span
14301430- if let Some(&(ref_span_index, ref_char_start)) =
14311431- self.footnote_ref_spans.get(&name)
14321432- {
14331433- // Create formatted_range spanning from ref start to def end
14341434- let formatted_range = ref_char_start..def_char_end;
14351435-14361436- // Update both spans with the same formatted_range
14371437- // so they show/hide together based on cursor proximity
14381438- if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) {
14391439- ref_span.formatted_range = Some(formatted_range.clone());
14401440- }
14411441- if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) {
14421442- def_span.formatted_range = Some(formatted_range);
14431443- }
14441444- }
14851485+ // Finalize paragraph boundary for incremental rendering
14861486+ if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
14871487+ let byte_range = byte_start..self.last_byte_offset;
14881488+ let char_range = char_start..self.last_char_offset;
14891489+ self.finalize_paragraph(byte_range, char_range);
14451490 }
1446149114471492 Ok(())
···208208 ///
209209 /// Compares edit_heads to draft_titles to find drafts where the current
210210 /// head doesn't match the head used for title extraction.
211211- pub async fn get_stale_draft_titles(&self, limit: i64) -> Result<Vec<StaleDraftRow>, IndexError> {
211211+ pub async fn get_stale_draft_titles(
212212+ &self,
213213+ limit: i64,
214214+ ) -> Result<Vec<StaleDraftRow>, IndexError> {
212215 // Join drafts -> edit_heads (for current head) -> draft_titles (to check staleness)
213216 // edit_heads uses resource_type='draft' and resource_did/resource_rkey to link
214217 let query = r#"
215218 SELECT
216216- d.did,
217217- d.rkey,
218218- h.head_did,
219219- h.head_rkey,
220220- h.head_cid,
221221- h.root_did,
222222- h.root_rkey,
223223- h.root_cid
219219+ d.did as did,
220220+ d.rkey as rkey,
221221+ h.head_did as head_did,
222222+ h.head_rkey as head_rkey,
223223+ h.head_cid as head_cid,
224224+ h.root_did as root_did,
225225+ h.root_rkey as root_rkey,
226226+ h.root_cid as root_cid
224227 FROM drafts d FINAL
225228 INNER JOIN edit_heads h FINAL
226229 ON h.resource_did = d.did
···22source: crates/weaver-renderer/src/atproto/tests.rs
33expression: output
44---
55-<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>
55+<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>
···22source: crates/weaver-renderer/src/atproto/tests.rs
33expression: output
44---
55-<p>This is a paragraph.</p>
66-<p>This is another paragraph.</p>
55+<p dir="ltr">This is a paragraph.</p>
66+<p dir="ltr">This is another paragraph.</p>
···33expression: output
44---
55<aside>
66-<p>This paragraph should be in an aside.</p>
66+<p dir="ltr">This paragraph should be in an aside.</p>
77</aside>
···44---
55<aside>
66<blockquote>
77-<p>This blockquote is in an aside.</p>
77+<p dir="ltr">This blockquote is in an aside.</p>
88</aside>
99</blockquote>
···33expression: output
44---
55<div class="foo" width="300px" data-test="value">
66-<p>Paragraph with class and attributes.</p>
66+<p dir="ltr">Paragraph with class and attributes.</p>
77</div>
···33expression: output
44---
55<div class="highlight">
66-<p>This paragraph has a custom class.</p>
66+<p dir="ltr">This paragraph has a custom class.</p>
77</div>
···33expression: output
44---
55<aside>
66-<p>First paragraph in aside.</p>
66+<p dir="ltr">First paragraph in aside.</p>
77</aside>
88-<p>Second paragraph NOT in aside.</p>
88+<p dir="ltr">Second paragraph NOT in aside.</p>
···22source: crates/weaver-renderer/src/static_site/tests.rs
33expression: output
44---
55-<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>
55+<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>
···22source: crates/weaver-renderer/src/static_site/tests.rs
33expression: output
44---
55-<p>This is a paragraph.</p>
66-<p>This is another paragraph.</p>
55+<p dir="ltr">This is a paragraph.</p>
66+<p dir="ltr">This is another paragraph.</p>
···33expression: output
44---
55<aside>
66-<p>This paragraph should be in an aside.</p>
66+<p dir="ltr">This paragraph should be in an aside.</p>
77</aside>
···44---
55<aside>
66<blockquote>
77-<p>This blockquote is in an aside.</p>
77+<p dir="ltr">This blockquote is in an aside.</p>
88</blockquote>
99</aside>
···33expression: output
44---
55<div class="foo" width="300px" data-test="value">
66-<p>Paragraph with class and attributes.</p>
66+<p dir="ltr">Paragraph with class and attributes.</p>
77</div>
···33expression: output
44---
55<div class="highlight">
66-<p>This paragraph has a custom class.</p>
66+<p dir="ltr">This paragraph has a custom class.</p>
77</div>
···33expression: output
44---
55<aside>
66-<p>First paragraph in aside.</p>
66+<p dir="ltr">First paragraph in aside.</p>
77</aside>
88-<p>Second paragraph NOT in aside.</p>
88+<p dir="ltr">Second paragraph NOT in aside.</p>
···22222323{.aside}
2424> **On Platform Decay**
2525->
2625> 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.
27262827But 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.