tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
basic thing works, really bad perf
Orual
2 months ago
468ca19d
25836bdb
+740
-73
15 changed files
expand all
collapse all
unified
split
.gitignore
crates
weaver-app
.env-example
.env-prod
assets
styling
editor.css
src
components
editor
component.rs
document.rs
image_upload.rs
mod.rs
publish.rs
render.rs
storage.rs
tests.rs
toolbar.rs
writer.rs
env.rs
+1
.gitignore
···
18
18
**/.obsidian
19
19
**/.trash
20
20
**/bug_notes.md
21
21
+
/opencodetmp
-10
crates/weaver-app/.env-example
···
1
1
-
WEAVER_APP_ENV="dev"
2
2
-
WEAVER_APP_HOST="http://localhost"
3
3
-
WEAVER_APP_DOMAIN=""
4
4
-
WEAVER_PORT=8080
5
5
-
WEAVER_APP_SCOPES="atproto transition:generic"
6
6
-
WEAVER_CLIENT_NAME="Weaver"
7
7
-
8
8
-
WEAVER_LOGO_URI=""
9
9
-
WEAVER_TOS_URI=""
10
10
-
WEAVER_PRIVACY_POLICY_URI=""
+10
crates/weaver-app/.env-prod
···
1
1
+
WEAVER_APP_ENV="prod"
2
2
+
WEAVER_APP_HOST="https://alpha.weaver.sh"
3
3
+
WEAVER_APP_DOMAIN="https://alpha.weaver.sh"
4
4
+
WEAVER_PORT=8080
5
5
+
WEAVER_APP_SCOPES="atproto transition:generic"
6
6
+
WEAVER_CLIENT_NAME="Weaver"
7
7
+
8
8
+
WEAVER_LOGO_URI="https://alpha.weaver.sh/favicon.ico"
9
9
+
WEAVER_TOS_URI=""
10
10
+
WEAVER_PRIVACY_POLICY_URI=""
+47
crates/weaver-app/assets/styling/editor.css
···
565
565
opacity: 0.5;
566
566
cursor: not-allowed;
567
567
}
568
568
+
569
569
+
/* Image upload dialog */
570
570
+
.image-preview-container {
571
571
+
display: flex;
572
572
+
justify-content: center;
573
573
+
margin-bottom: 1rem;
574
574
+
}
575
575
+
576
576
+
.image-preview {
577
577
+
max-width: 100%;
578
578
+
max-height: 300px;
579
579
+
border-radius: 4px;
580
580
+
object-fit: contain;
581
581
+
}
582
582
+
583
583
+
.image-alt-input-container {
584
584
+
display: flex;
585
585
+
flex-direction: column;
586
586
+
gap: 0.5rem;
587
587
+
}
588
588
+
589
589
+
.image-alt-input-container label {
590
590
+
font-weight: 500;
591
591
+
color: var(--color-text);
592
592
+
}
593
593
+
594
594
+
.image-alt-input {
595
595
+
width: 100%;
596
596
+
padding: 0.75rem;
597
597
+
border: 1px solid var(--color-border);
598
598
+
border-radius: 4px;
599
599
+
background: var(--color-base);
600
600
+
color: var(--color-text);
601
601
+
font-family: var(--font-body);
602
602
+
resize: vertical;
603
603
+
}
604
604
+
605
605
+
.image-alt-input::placeholder {
606
606
+
color: var(--color-muted);
607
607
+
}
608
608
+
609
609
+
.dialog-actions {
610
610
+
display: flex;
611
611
+
gap: 1rem;
612
612
+
justify-content: flex-end;
613
613
+
margin-top: 1rem;
614
614
+
}
+124
-4
crates/weaver-app/src/components/editor/component.rs
···
1
1
//! The main MarkdownEditor component.
2
2
3
3
use dioxus::prelude::*;
4
4
+
use jacquard::cowstr::ToCowStr;
5
5
+
use jacquard::types::blob::BlobRef;
6
6
+
use weaver_api::sh_weaver::embed::images::Image;
7
7
+
use weaver_common::WeaverExt;
4
8
9
9
+
use crate::auth::AuthState;
5
10
use crate::components::editor::ReportButton;
11
11
+
use crate::fetch::Fetcher;
6
12
7
13
use super::document::{CompositionState, EditorDocument};
8
14
use super::dom_sync::{sync_cursor_from_dom, update_paragraph_dom};
···
17
23
use super::storage;
18
24
use super::toolbar::EditorToolbar;
19
25
use super::visibility::update_syntax_visibility;
20
20
-
use super::writer::SyntaxSpanInfo;
26
26
+
use super::writer::{EditorImageResolver, SyntaxSpanInfo};
21
27
22
28
/// Main markdown editor component.
23
29
///
···
32
38
/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
33
39
#[component]
34
40
pub fn MarkdownEditor(initial_content: Option<String>) -> Element {
41
41
+
// Context for authenticated API calls
42
42
+
let fetcher = use_context::<Fetcher>();
43
43
+
let auth_state = use_context::<Signal<AuthState>>();
44
44
+
35
45
// Try to restore from localStorage (includes CRDT state for undo history)
36
46
// Use "current" as the default draft key for now
37
47
let draft_key = "current";
···
44
54
// Cache for incremental paragraph rendering
45
55
let mut render_cache = use_signal(|| render::RenderCache::default());
46
56
57
57
+
// Image resolver for mapping /image/{name} to data URLs or CDN URLs
58
58
+
let mut image_resolver = use_signal(EditorImageResolver::default);
59
59
+
47
60
// Render paragraphs with incremental caching
48
61
let paragraphs = use_memo(move || {
49
62
let doc = document();
50
63
let cache = render_cache.peek();
51
64
let edit = doc.last_edit.as_ref();
65
65
+
let resolver = image_resolver();
52
66
53
67
let (paras, new_cache) =
54
54
-
render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit);
68
68
+
render::render_paragraphs_incremental(doc.loro_text(), Some(&cache), edit, Some(&resolver));
55
69
56
70
// Update cache for next render (write-only via spawn to avoid reactive loop)
57
71
dioxus::prelude::spawn(async move {
···
161
175
let needs_save = {
162
176
let last_frontiers = last_saved_frontiers.peek();
163
177
match &*last_frontiers {
164
164
-
None => true, // First save
178
178
+
None => true,
165
179
Some(last) => ¤t_frontiers != last,
166
180
}
167
181
}; // drop last_frontiers borrow here
···
169
183
if needs_save {
170
184
document.with_mut(|doc| {
171
185
doc.sync_loro_cursor();
172
172
-
let _ = storage::save_to_storage(doc, draft_key, None);
186
186
+
let _ = storage::save_to_storage(doc, draft_key);
173
187
});
174
188
175
189
// Update last saved frontiers
···
650
664
document.with_mut(|doc| {
651
665
formatting::apply_formatting(doc, action);
652
666
});
667
667
+
},
668
668
+
on_image: move |uploaded: super::image_upload::UploadedImage| {
669
669
+
// Build data URL for immediate preview
670
670
+
use base64::{Engine, engine::general_purpose::STANDARD};
671
671
+
let data_url = format!(
672
672
+
"data:{};base64,{}",
673
673
+
uploaded.mime_type,
674
674
+
STANDARD.encode(&uploaded.data)
675
675
+
);
676
676
+
677
677
+
// Add to resolver for immediate display
678
678
+
let name = uploaded.name.clone();
679
679
+
image_resolver.with_mut(|resolver| {
680
680
+
resolver.add_pending(name.clone(), data_url);
681
681
+
});
682
682
+
683
683
+
// Insert markdown image syntax at cursor
684
684
+
let alt_text = if uploaded.alt.is_empty() {
685
685
+
name.clone()
686
686
+
} else {
687
687
+
uploaded.alt.clone()
688
688
+
};
689
689
+
let markdown = format!("", alt_text, name);
690
690
+
691
691
+
document.with_mut(|doc| {
692
692
+
let pos = doc.cursor.offset;
693
693
+
let _ = doc.insert_tracked(pos, &markdown);
694
694
+
doc.cursor.offset = pos + markdown.chars().count();
695
695
+
});
696
696
+
697
697
+
// Upload to PDS in background if authenticated
698
698
+
let is_authenticated = auth_state.read().is_authenticated();
699
699
+
if is_authenticated {
700
700
+
let fetcher = fetcher.clone();
701
701
+
let name_for_upload = name.clone();
702
702
+
let alt_for_upload = alt_text.clone();
703
703
+
let data = uploaded.data.clone();
704
704
+
705
705
+
spawn(async move {
706
706
+
let client = fetcher.get_client();
707
707
+
708
708
+
// Upload blob and create temporary PublishedBlob record
709
709
+
match client.publish_blob(data, &name_for_upload, None).await {
710
710
+
Ok((strong_ref, published_blob)) => {
711
711
+
// Extract the blob from PublishedBlob
712
712
+
let blob = match published_blob.upload {
713
713
+
BlobRef::Blob(b) => b,
714
714
+
_ => {
715
715
+
tracing::warn!("Unexpected BlobRef variant");
716
716
+
return;
717
717
+
}
718
718
+
};
719
719
+
720
720
+
// Get format from mime type
721
721
+
let format = blob
722
722
+
.mime_type
723
723
+
.0
724
724
+
.strip_prefix("image/")
725
725
+
.unwrap_or("jpeg")
726
726
+
.to_string();
727
727
+
728
728
+
// Get DID from fetcher
729
729
+
let did = match fetcher.current_did().await {
730
730
+
Some(d) => d.to_string(),
731
731
+
None => {
732
732
+
tracing::warn!("No DID available");
733
733
+
return;
734
734
+
}
735
735
+
};
736
736
+
737
737
+
let cid = blob.cid().to_string();
738
738
+
739
739
+
// Build Image using the builder API
740
740
+
let name_for_resolver = name_for_upload.clone();
741
741
+
let image = Image::new()
742
742
+
.alt(alt_for_upload.to_cowstr())
743
743
+
.image(BlobRef::Blob(blob))
744
744
+
.name(name_for_upload.to_cowstr())
745
745
+
.build();
746
746
+
747
747
+
// Add to document
748
748
+
document.with_mut(|doc| {
749
749
+
doc.add_image(&image, Some(&strong_ref.uri));
750
750
+
});
751
751
+
752
752
+
// Promote from pending to uploaded in resolver
753
753
+
image_resolver.with_mut(|resolver| {
754
754
+
resolver.promote_to_uploaded(
755
755
+
&name_for_resolver,
756
756
+
cid,
757
757
+
did,
758
758
+
format,
759
759
+
);
760
760
+
});
761
761
+
762
762
+
tracing::info!(name = %name_for_resolver, "Image uploaded to PDS");
763
763
+
}
764
764
+
Err(e) => {
765
765
+
tracing::error!(error = %e, "Failed to upload image");
766
766
+
// Image stays as data URL - will work for preview but not publish
767
767
+
}
768
768
+
}
769
769
+
});
770
770
+
} else {
771
771
+
tracing::info!(name = %name, "Image added with data URL (not authenticated)");
772
772
+
}
653
773
}
654
774
}
655
775
+20
crates/weaver-app/src/components/editor/document.rs
···
55
55
/// Contains nested containers: images (LoroList), externals (LoroList), etc.
56
56
embeds: LoroMap,
57
57
58
58
+
// --- Entry tracking ---
59
59
+
/// AT-URI of the entry if editing an existing record.
60
60
+
/// None for new entries that haven't been published yet.
61
61
+
entry_uri: Option<AtUri<'static>>,
62
62
+
58
63
// --- Editor state ---
59
64
/// Undo manager for the document.
60
65
undo_mgr: UndoManager,
···
205
210
created_at,
206
211
tags,
207
212
embeds,
213
213
+
entry_uri: None,
208
214
undo_mgr,
209
215
cursor: CursorState {
210
216
offset: 0,
···
312
318
self.created_at.delete(0, current_len).ok();
313
319
}
314
320
self.created_at.insert(0, datetime).ok();
321
321
+
}
322
322
+
323
323
+
// --- Entry URI accessors ---
324
324
+
325
325
+
/// Get the AT-URI of the entry if editing an existing record.
326
326
+
pub fn entry_uri(&self) -> Option<&AtUri<'static>> {
327
327
+
self.entry_uri.as_ref()
328
328
+
}
329
329
+
330
330
+
/// Set the AT-URI when editing an existing entry.
331
331
+
pub fn set_entry_uri(&mut self, uri: Option<AtUri<'static>>) {
332
332
+
self.entry_uri = uri;
315
333
}
316
334
317
335
// --- Tags accessors ---
···
690
708
created_at,
691
709
tags,
692
710
embeds,
711
711
+
entry_uri: None,
693
712
undo_mgr,
694
713
cursor,
695
714
loro_cursor,
···
718
737
new_doc.composition = self.composition.clone();
719
738
new_doc.composition_ended_at = self.composition_ended_at;
720
739
new_doc.last_edit = self.last_edit.clone();
740
740
+
new_doc.entry_uri = self.entry_uri.clone();
721
741
new_doc
722
742
}
723
743
}
+176
crates/weaver-app/src/components/editor/image_upload.rs
···
1
1
+
//! Image upload component for the markdown editor.
2
2
+
//!
3
3
+
//! Provides file picker and upload functionality for adding images to entries.
4
4
+
//! Shows a preview dialog with alt text input before confirming the upload.
5
5
+
6
6
+
use base64::{Engine, engine::general_purpose::STANDARD};
7
7
+
use dioxus::prelude::*;
8
8
+
use jacquard::bytes::Bytes;
9
9
+
use mime_sniffer::MimeTypeSniffer;
10
10
+
11
11
+
use crate::components::{
12
12
+
button::{Button, ButtonVariant},
13
13
+
dialog::{DialogContent, DialogRoot, DialogTitle},
14
14
+
};
15
15
+
16
16
+
/// Result of an image upload operation.
17
17
+
#[derive(Clone, Debug)]
18
18
+
pub struct UploadedImage {
19
19
+
/// The filename (used as the markdown reference name)
20
20
+
pub name: String,
21
21
+
/// Alt text for accessibility
22
22
+
pub alt: String,
23
23
+
/// MIME type of the image (sniffed from bytes)
24
24
+
pub mime_type: String,
25
25
+
/// Raw image bytes
26
26
+
pub data: Bytes,
27
27
+
}
28
28
+
29
29
+
/// Pending image data before user confirms with alt text.
30
30
+
#[derive(Clone, Default)]
31
31
+
struct PendingImage {
32
32
+
name: String,
33
33
+
mime_type: String,
34
34
+
data: Bytes,
35
35
+
data_url: String,
36
36
+
}
37
37
+
38
38
+
/// Props for the ImageUploadButton component.
39
39
+
#[derive(Props, Clone, PartialEq)]
40
40
+
pub struct ImageUploadButtonProps {
41
41
+
/// Callback when an image is selected and confirmed with alt text
42
42
+
pub on_image_selected: EventHandler<UploadedImage>,
43
43
+
/// Whether the button is disabled
44
44
+
#[props(default = false)]
45
45
+
pub disabled: bool,
46
46
+
}
47
47
+
48
48
+
/// A button that opens a file picker for image selection.
49
49
+
///
50
50
+
/// When a file is selected, shows a preview dialog with alt text input.
51
51
+
/// Only triggers the callback after user confirms.
52
52
+
#[component]
53
53
+
pub fn ImageUploadButton(props: ImageUploadButtonProps) -> Element {
54
54
+
let mut show_dialog = use_signal(|| false);
55
55
+
let mut pending_image = use_signal(PendingImage::default);
56
56
+
let mut alt_text = use_signal(String::new);
57
57
+
58
58
+
let on_file_change = move |evt: Event<FormData>| {
59
59
+
spawn(async move {
60
60
+
let files = evt.files();
61
61
+
if let Some(file) = files.first() {
62
62
+
let name = file.name();
63
63
+
64
64
+
if let Ok(data) = file.read_bytes().await {
65
65
+
let bytes = Bytes::from(data);
66
66
+
let mime_type = bytes
67
67
+
.sniff_mime_type()
68
68
+
.unwrap_or("application/octet-stream")
69
69
+
.to_string();
70
70
+
71
71
+
let data_url = format!("data:{};base64,{}", mime_type, STANDARD.encode(&bytes));
72
72
+
73
73
+
pending_image.set(PendingImage {
74
74
+
name: name.clone(),
75
75
+
mime_type,
76
76
+
data: bytes,
77
77
+
data_url,
78
78
+
});
79
79
+
alt_text.set(String::new());
80
80
+
show_dialog.set(true);
81
81
+
}
82
82
+
}
83
83
+
});
84
84
+
};
85
85
+
86
86
+
let on_image_selected = props.on_image_selected.clone();
87
87
+
let confirm_upload = move |_| {
88
88
+
let pending = pending_image();
89
89
+
let uploaded = UploadedImage {
90
90
+
name: pending.name,
91
91
+
alt: alt_text(),
92
92
+
mime_type: pending.mime_type,
93
93
+
data: pending.data,
94
94
+
};
95
95
+
on_image_selected.call(uploaded);
96
96
+
show_dialog.set(false);
97
97
+
pending_image.set(PendingImage::default());
98
98
+
alt_text.set(String::new());
99
99
+
};
100
100
+
101
101
+
let cancel_upload = move |_| {
102
102
+
show_dialog.set(false);
103
103
+
pending_image.set(PendingImage::default());
104
104
+
alt_text.set(String::new());
105
105
+
};
106
106
+
107
107
+
rsx! {
108
108
+
label {
109
109
+
class: "toolbar-button",
110
110
+
title: "Image",
111
111
+
input {
112
112
+
r#type: "file",
113
113
+
accept: "image/*",
114
114
+
style: "display: none;",
115
115
+
disabled: props.disabled,
116
116
+
onchange: on_file_change,
117
117
+
}
118
118
+
"🖼"
119
119
+
}
120
120
+
121
121
+
DialogRoot {
122
122
+
open: show_dialog(),
123
123
+
on_open_change: move |v| show_dialog.set(v),
124
124
+
125
125
+
DialogContent {
126
126
+
button {
127
127
+
class: "dialog-close",
128
128
+
r#type: "button",
129
129
+
aria_label: "Close",
130
130
+
tabindex: if show_dialog() { "0" } else { "-1" },
131
131
+
onclick: cancel_upload,
132
132
+
"×"
133
133
+
}
134
134
+
135
135
+
DialogTitle { "Add Image" }
136
136
+
137
137
+
div { class: "image-preview-container",
138
138
+
img {
139
139
+
class: "image-preview",
140
140
+
src: "{pending_image().data_url}",
141
141
+
alt: "Preview",
142
142
+
}
143
143
+
}
144
144
+
145
145
+
div { class: "image-alt-input-container",
146
146
+
label {
147
147
+
r#for: "image-alt-text",
148
148
+
"Alt text"
149
149
+
}
150
150
+
textarea {
151
151
+
id: "image-alt-text",
152
152
+
class: "image-alt-input",
153
153
+
placeholder: "Describe this image for people who can't see it",
154
154
+
value: "{alt_text}",
155
155
+
oninput: move |e| alt_text.set(e.value()),
156
156
+
rows: "3",
157
157
+
}
158
158
+
}
159
159
+
160
160
+
div { class: "dialog-actions",
161
161
+
Button {
162
162
+
r#type: "button",
163
163
+
onclick: cancel_upload,
164
164
+
variant: ButtonVariant::Secondary,
165
165
+
"Cancel"
166
166
+
}
167
167
+
Button {
168
168
+
r#type: "button",
169
169
+
onclick: confirm_upload,
170
170
+
"Add Image"
171
171
+
}
172
172
+
}
173
173
+
}
174
174
+
}
175
175
+
}
176
176
+
}
+3
-1
crates/weaver-app/src/components/editor/mod.rs
···
9
9
mod document;
10
10
mod dom_sync;
11
11
mod formatting;
12
12
+
mod image_upload;
12
13
mod input;
13
14
mod log_buffer;
14
15
mod offset_map;
···
44
45
#[allow(unused_imports)]
45
46
pub use render::{RenderCache, render_paragraphs_incremental};
46
47
#[allow(unused_imports)]
47
47
-
pub use writer::{SyntaxSpanInfo, SyntaxType, WriterResult};
48
48
+
pub use writer::{EditorImageResolver, ImageResolver, SyntaxSpanInfo, SyntaxType, WriterResult};
48
49
49
50
// Storage
50
51
#[allow(unused_imports)]
···
54
55
};
55
56
56
57
// UI components
58
58
+
pub use image_upload::{ImageUploadButton, UploadedImage};
57
59
pub use publish::PublishButton;
58
60
pub use report::ReportButton;
59
61
#[allow(unused_imports)]
+116
-23
crates/weaver-app/src/components/editor/publish.rs
···
3
3
//! Handles creating/updating AT Protocol notebook entries from editor state.
4
4
5
5
use dioxus::prelude::*;
6
6
-
use jacquard::types::string::{AtUri, Datetime};
6
6
+
use jacquard::types::ident::AtIdentifier;
7
7
+
use jacquard::types::string::{AtUri, Datetime, Nsid};
8
8
+
use jacquard::{IntoStatic, prelude::*, to_data};
9
9
+
use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord};
7
10
use weaver_api::sh_weaver::embed::images::Images;
8
11
use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds};
9
12
use weaver_common::{WeaverError, WeaverExt};
13
13
+
14
14
+
const ENTRY_NSID: &str = "sh.weaver.notebook.entry";
10
15
11
16
use crate::auth::AuthState;
12
17
use crate::fetch::Fetcher;
···
33
38
34
39
/// Publish an entry to the AT Protocol.
35
40
///
41
41
+
/// Supports three modes:
42
42
+
/// - With notebook_title: uses `upsert_entry` to publish to a notebook
43
43
+
/// - Without notebook but with entry_uri in doc: uses `put_record` to update existing
44
44
+
/// - Without notebook and no entry_uri: uses `create_record` for free-floating entry
45
45
+
///
36
46
/// # Arguments
37
47
/// * `fetcher` - The authenticated fetcher/client
38
48
/// * `doc` - The editor document containing entry data
39
39
-
/// * `notebook_title` - Title of the notebook to publish to
49
49
+
/// * `notebook_title` - Optional title of the notebook to publish to
40
50
/// * `draft_key` - Storage key for the draft (for cleanup)
41
51
///
42
52
/// # Returns
···
44
54
pub async fn publish_entry(
45
55
fetcher: &Fetcher,
46
56
doc: &EditorDocument,
47
47
-
notebook_title: &str,
57
57
+
notebook_title: Option<&str>,
48
58
draft_key: &str,
49
59
) -> Result<PublishResult, WeaverError> {
50
60
// Get images from the document
···
95
105
.maybe_tags(tags)
96
106
.maybe_embeds(entry_embeds)
97
107
.build();
108
108
+
let entry_data = to_data(&entry).unwrap();
98
109
99
99
-
// Publish via upsert_entry
100
110
let client = fetcher.get_client();
101
101
-
let (uri, was_created) = client
102
102
-
.upsert_entry(notebook_title, &doc.title(), entry)
103
103
-
.await?;
111
111
+
let result = if let Some(notebook) = notebook_title {
112
112
+
// Publish to a notebook via upsert_entry
113
113
+
let (uri, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?;
114
114
+
115
115
+
if was_created {
116
116
+
PublishResult::Created(uri)
117
117
+
} else {
118
118
+
PublishResult::Updated(uri)
119
119
+
}
120
120
+
} else if let Some(existing_uri) = doc.entry_uri() {
121
121
+
// Update existing free-floating entry
122
122
+
let did = fetcher
123
123
+
.current_did()
124
124
+
.await
125
125
+
.ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
126
126
+
127
127
+
let rkey = existing_uri
128
128
+
.rkey()
129
129
+
.ok_or_else(|| WeaverError::InvalidNotebook("Entry URI missing rkey".into()))?;
130
130
+
131
131
+
let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?;
132
132
+
133
133
+
let request = PutRecord::new()
134
134
+
.repo(AtIdentifier::Did(did))
135
135
+
.collection(collection)
136
136
+
.rkey(rkey.clone())
137
137
+
.record(entry_data)
138
138
+
.build();
139
139
+
140
140
+
let response = fetcher
141
141
+
.send(request)
142
142
+
.await
143
143
+
.map_err(jacquard::client::AgentError::from)?;
144
144
+
let output = response
145
145
+
.into_output()
146
146
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
147
147
+
148
148
+
PublishResult::Updated(output.uri.into_static())
149
149
+
} else {
150
150
+
// Create new free-floating entry
151
151
+
let did = fetcher
152
152
+
.current_did()
153
153
+
.await
154
154
+
.ok_or_else(|| WeaverError::InvalidNotebook("Not authenticated".into()))?;
155
155
+
156
156
+
let collection = Nsid::new(ENTRY_NSID).map_err(|e| WeaverError::AtprotoString(e))?;
157
157
+
158
158
+
let request = CreateRecord::new()
159
159
+
.repo(AtIdentifier::Did(did))
160
160
+
.collection(collection)
161
161
+
.record(entry_data)
162
162
+
.build();
163
163
+
164
164
+
let response = fetcher
165
165
+
.send(request)
166
166
+
.await
167
167
+
.map_err(jacquard::client::AgentError::from)?;
168
168
+
let output = response
169
169
+
.into_output()
170
170
+
.map_err(|e| WeaverError::InvalidNotebook(e.to_string()))?;
171
171
+
172
172
+
PublishResult::Created(output.uri.into_static())
173
173
+
};
104
174
105
175
// Cleanup: delete PublishedBlob records (entry's embed refs now keep blobs alive)
106
176
// TODO: Implement when image upload is added
···
113
183
// Clear local draft
114
184
delete_draft(draft_key);
115
185
116
116
-
if was_created {
117
117
-
Ok(PublishResult::Created(uri))
118
118
-
} else {
119
119
-
Ok(PublishResult::Updated(uri))
120
120
-
}
186
186
+
Ok(result)
121
187
}
122
188
123
189
/// Simple slug generation from title.
···
161
227
162
228
let mut show_dialog = use_signal(|| false);
163
229
let mut notebook_title = use_signal(|| String::from("Default"));
230
230
+
let mut use_notebook = use_signal(|| true);
164
231
let mut is_publishing = use_signal(|| false);
165
232
let mut error_message: Signal<Option<String>> = use_signal(|| None);
166
233
let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
···
168
235
let is_authenticated = auth_state.read().is_authenticated();
169
236
let doc = props.document;
170
237
let draft_key = props.draft_key.clone();
238
238
+
239
239
+
// Check if we're editing an existing entry
240
240
+
let is_editing_existing = doc().entry_uri().is_some();
171
241
172
242
// Validate that we have required fields
173
243
let can_publish = {
···
189
259
let do_publish = move |_| {
190
260
let fetcher = fetcher.clone();
191
261
let draft_key = draft_key_clone.clone();
192
192
-
let notebook = notebook_title();
262
262
+
let notebook = if use_notebook() {
263
263
+
Some(notebook_title())
264
264
+
} else {
265
265
+
None
266
266
+
};
193
267
194
268
spawn(async move {
195
269
is_publishing.set(true);
···
198
272
// Get document snapshot for publishing
199
273
let doc_snapshot = doc();
200
274
201
201
-
match publish_entry(&fetcher, &doc_snapshot, ¬ebook, &draft_key).await {
275
275
+
match publish_entry(&fetcher, &doc_snapshot, notebook.as_deref(), &draft_key).await {
202
276
Ok(result) => {
203
277
success_uri.set(Some(result.uri().clone()));
204
278
}
···
253
327
}
254
328
} else {
255
329
div { class: "publish-form",
256
256
-
div { class: "publish-field",
257
257
-
label { "Notebook" }
258
258
-
input {
259
259
-
r#type: "text",
260
260
-
class: "publish-input",
261
261
-
placeholder: "Notebook title...",
262
262
-
value: "{notebook_title}",
263
263
-
oninput: move |e| notebook_title.set(e.value()),
330
330
+
if is_editing_existing {
331
331
+
div { class: "publish-info",
332
332
+
p { "Updating existing entry" }
333
333
+
}
334
334
+
}
335
335
+
336
336
+
div { class: "publish-field publish-checkbox",
337
337
+
label {
338
338
+
input {
339
339
+
r#type: "checkbox",
340
340
+
checked: use_notebook(),
341
341
+
onchange: move |e| use_notebook.set(e.checked()),
342
342
+
}
343
343
+
" Publish to notebook"
344
344
+
}
345
345
+
}
346
346
+
347
347
+
if use_notebook() {
348
348
+
div { class: "publish-field",
349
349
+
label { "Notebook" }
350
350
+
input {
351
351
+
r#type: "text",
352
352
+
class: "publish-input",
353
353
+
placeholder: "Notebook title...",
354
354
+
value: "{notebook_title}",
355
355
+
oninput: move |e| notebook_title.set(e.value()),
356
356
+
}
264
357
}
265
358
}
266
359
···
288
381
button {
289
382
class: "publish-submit",
290
383
onclick: do_publish,
291
291
-
disabled: is_publishing() || notebook_title().trim().is_empty(),
384
384
+
disabled: is_publishing() || (use_notebook() && notebook_title().trim().is_empty()),
292
385
if is_publishing() {
293
386
"Publishing..."
294
387
} else {
+8
-2
crates/weaver-app/src/components/editor/render.rs
···
7
7
use super::document::EditInfo;
8
8
use super::offset_map::{OffsetMapping, RenderResult};
9
9
use super::paragraph::{ParagraphRender, hash_source, text_slice_to_string};
10
10
-
use super::writer::{EditorWriter, SyntaxSpanInfo};
10
10
+
use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo};
11
11
use loro::LoroText;
12
12
use markdown_weaver::Parser;
13
13
use std::ops::Range;
···
112
112
/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
113
113
///
114
114
/// # Arguments
115
115
-
/// - `rope`: The document rope to render
115
115
+
/// - `text`: The document text to render
116
116
/// - `cache`: Previous render cache (if any)
117
117
/// - `edit`: Information about the most recent edit (if any)
118
118
+
/// - `image_resolver`: Optional resolver for mapping image URLs to data/CDN URLs
118
119
///
119
120
/// # Returns
120
121
/// Tuple of (rendered paragraphs, updated cache)
···
122
123
text: &LoroText,
123
124
cache: Option<&RenderCache>,
124
125
edit: Option<&EditInfo>,
126
126
+
image_resolver: Option<&EditorImageResolver>,
125
127
) -> (Vec<ParagraphRender>, RenderCache) {
126
128
let source = text.to_string();
127
129
···
297
299
.into_offset_iter();
298
300
let mut output = String::new();
299
301
302
302
+
// Use provided resolver or empty default
303
303
+
let resolver = image_resolver.cloned().unwrap_or_default();
304
304
+
300
305
let (mut offset_map, mut syntax_spans) =
301
306
match EditorWriter::<_, _, ()>::new_with_offsets(
302
307
¶_source,
···
306
311
node_id_offset,
307
312
syn_id_offset,
308
313
)
314
314
+
.with_image_resolver(&resolver)
309
315
.run()
310
316
{
311
317
Ok(result) => {
+14
-9
crates/weaver-app/src/components/editor/storage.rs
···
11
11
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
12
12
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
13
13
use gloo_storage::{LocalStorage, Storage};
14
14
+
use jacquard::IntoStatic;
15
15
+
use jacquard::types::string::AtUri;
14
16
use loro::cursor::Cursor;
15
17
use serde::{Deserialize, Serialize};
16
18
···
67
69
/// # Arguments
68
70
/// * `doc` - The editor document to save
69
71
/// * `key` - Storage key (e.g., "new:abc123" for new entries, or AT-URI for existing)
70
70
-
/// * `editing_uri` - AT-URI if editing an existing entry
71
72
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
72
73
pub fn save_to_storage(
73
74
doc: &EditorDocument,
74
75
key: &str,
75
75
-
editing_uri: Option<&str>,
76
76
) -> Result<(), gloo_storage::errors::StorageError> {
77
77
let snapshot_bytes = doc.export_snapshot();
78
78
let snapshot_b64 = if snapshot_bytes.is_empty() {
···
87
87
snapshot: snapshot_b64,
88
88
cursor: doc.loro_cursor().cloned(),
89
89
cursor_offset: doc.cursor.offset,
90
90
-
editing_uri: editing_uri.map(String::from),
90
90
+
editing_uri: doc.entry_uri().map(|u| u.to_string()),
91
91
};
92
92
LocalStorage::set(storage_key(key), &snapshot)
93
93
}
···
103
103
pub fn load_from_storage(key: &str) -> Option<EditorDocument> {
104
104
let snapshot: EditorSnapshot = LocalStorage::get(storage_key(key)).ok()?;
105
105
106
106
+
// Parse entry_uri from the snapshot
107
107
+
let entry_uri = snapshot
108
108
+
.editing_uri
109
109
+
.as_ref()
110
110
+
.and_then(|s| AtUri::new(s).ok())
111
111
+
.map(|u| u.into_static());
112
112
+
106
113
// Try to restore from CRDT snapshot first
107
114
if let Some(ref snapshot_b64) = snapshot.snapshot {
108
115
if let Ok(snapshot_bytes) = BASE64.decode(snapshot_b64) {
109
109
-
let doc = EditorDocument::from_snapshot(
116
116
+
let mut doc = EditorDocument::from_snapshot(
110
117
&snapshot_bytes,
111
118
snapshot.cursor.clone(),
112
119
snapshot.cursor_offset,
113
120
);
114
121
// Verify the content matches (sanity check)
115
122
if doc.content() == snapshot.content {
123
123
+
doc.set_entry_uri(entry_uri);
116
124
return Some(doc);
117
125
}
118
126
tracing::warn!("Snapshot content mismatch, falling back to text content");
···
123
131
let mut doc = EditorDocument::new(snapshot.content);
124
132
doc.cursor.offset = snapshot.cursor_offset.min(doc.len_chars());
125
133
doc.sync_loro_cursor();
134
134
+
doc.set_entry_uri(entry_uri);
126
135
Some(doc)
127
136
}
128
137
···
176
185
177
186
// Stub implementations for non-WASM targets
178
187
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
179
179
-
pub fn save_to_storage(
180
180
-
_doc: &EditorDocument,
181
181
-
_key: &str,
182
182
-
_editing_uri: Option<&str>,
183
183
-
) -> Result<(), String> {
188
188
+
pub fn save_to_storage(_doc: &EditorDocument, _key: &str) -> Result<(), String> {
184
189
Ok(())
185
190
}
186
191
+7
-7
crates/weaver-app/src/components/editor/tests.rs
···
57
57
let doc = LoroDoc::new();
58
58
let text = doc.get_text("content");
59
59
text.insert(0, input).unwrap();
60
60
-
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
60
60
+
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
61
61
paragraphs.iter().map(TestParagraph::from).collect()
62
62
}
63
63
···
645
645
646
646
// Initial state: "#" is a valid empty heading
647
647
text.insert(0, "#").unwrap();
648
648
-
let (paras1, cache1) = render_paragraphs_incremental(&text, None, None);
648
648
+
let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None);
649
649
650
650
eprintln!("State 1 ('#'): {}", paras1[0].html);
651
651
assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···
656
656
657
657
// Transition: add "t" to make "#t" - no longer a heading
658
658
text.insert(1, "t").unwrap();
659
659
-
let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None);
659
659
+
let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None);
660
660
661
661
eprintln!("State 2 ('#t'): {}", paras2[0].html);
662
662
assert!(
···
765
765
let doc = LoroDoc::new();
766
766
let text = doc.get_text("content");
767
767
text.insert(0, input).unwrap();
768
768
-
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
768
768
+
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
769
769
770
770
// With standard \n\n break, we expect 2 paragraphs (no gap element)
771
771
// Paragraph ranges include some trailing whitespace from markdown parsing
···
794
794
let doc = LoroDoc::new();
795
795
let text = doc.get_text("content");
796
796
text.insert(0, input).unwrap();
797
797
-
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None);
797
797
+
let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
798
798
799
799
// With extra newlines, we expect 3 elements: para, gap, para
800
800
assert_eq!(
···
894
894
let text = doc.get_text("content");
895
895
text.insert(0, input).unwrap();
896
896
897
897
-
let (paras1, cache1) = render_paragraphs_incremental(&text, None, None);
897
897
+
let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None);
898
898
assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
899
899
900
900
// Second render with same content should reuse cache
901
901
-
let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None);
901
901
+
let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None);
902
902
903
903
// Should produce identical output
904
904
assert_eq!(paras1.len(), paras2.len());
+7
-6
crates/weaver-app/src/components/editor/toolbar.rs
···
1
1
//! Editor toolbar component with formatting buttons.
2
2
3
3
use super::formatting::FormatAction;
4
4
+
use super::image_upload::{ImageUploadButton, UploadedImage};
4
5
use dioxus::prelude::*;
5
6
6
7
/// Editor toolbar with formatting buttons.
7
8
///
8
9
/// Provides buttons for common markdown formatting operations.
9
10
#[component]
10
10
-
pub fn EditorToolbar(on_format: EventHandler<FormatAction>) -> Element {
11
11
+
pub fn EditorToolbar(
12
12
+
on_format: EventHandler<FormatAction>,
13
13
+
on_image: EventHandler<UploadedImage>,
14
14
+
) -> Element {
11
15
rsx! {
12
16
div { class: "editor-toolbar",
13
17
button {
···
85
89
onclick: move |_| on_format.call(FormatAction::Link),
86
90
"🔗"
87
91
}
88
88
-
button {
89
89
-
class: "toolbar-button",
90
90
-
title: "Image",
91
91
-
onclick: move |_| on_format.call(FormatAction::Image),
92
92
-
"🖼"
92
92
+
ImageUploadButton {
93
93
+
on_image_selected: move |img| on_image.call(img),
93
94
}
94
95
}
95
96
}
+203
-7
crates/weaver-app/src/components/editor/writer.rs
···
103
103
}
104
104
}
105
105
106
106
+
/// Resolves image URLs to CDN URLs based on stored images.
107
107
+
///
108
108
+
/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png").
109
109
+
/// This trait maps those names to the actual CDN URL using the blob CID and owner DID.
110
110
+
pub trait ImageResolver {
111
111
+
/// Resolve an image URL from markdown to a CDN URL.
112
112
+
///
113
113
+
/// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL.
114
114
+
fn resolve_image_url(&self, url: &str) -> Option<String>;
115
115
+
}
116
116
+
117
117
+
impl ImageResolver for () {
118
118
+
fn resolve_image_url(&self, _url: &str) -> Option<String> {
119
119
+
None
120
120
+
}
121
121
+
}
122
122
+
123
123
+
/// Concrete image resolver that maps image names to URLs.
124
124
+
///
125
125
+
/// Supports two states for images:
126
126
+
/// - Pending: uses data URL for immediate preview while upload is in progress
127
127
+
/// - Uploaded: uses CDN URL format `https://cdn.bsky.app/img/feed_fullsize/plain/{did}/{cid}@{format}`
128
128
+
///
129
129
+
/// Image URLs in markdown use the format `/image/{name}`.
130
130
+
#[derive(Clone, Default)]
131
131
+
pub struct EditorImageResolver {
132
132
+
/// Pending images: name -> data URL (still uploading)
133
133
+
pending: std::collections::HashMap<String, String>,
134
134
+
/// Uploaded images: name -> (CID string, DID string, format)
135
135
+
uploaded: std::collections::HashMap<String, (String, String, String)>,
136
136
+
}
137
137
+
138
138
+
impl EditorImageResolver {
139
139
+
pub fn new() -> Self {
140
140
+
Self::default()
141
141
+
}
142
142
+
143
143
+
/// Add a pending image with a data URL for immediate preview.
144
144
+
///
145
145
+
/// # Arguments
146
146
+
/// * `name` - The image name used in markdown (e.g., "photo.jpg")
147
147
+
/// * `data_url` - The base64 data URL for preview
148
148
+
pub fn add_pending(&mut self, name: String, data_url: String) {
149
149
+
self.pending.insert(name, data_url);
150
150
+
}
151
151
+
152
152
+
/// Promote a pending image to uploaded status.
153
153
+
///
154
154
+
/// Removes from pending and adds to uploaded with CDN info.
155
155
+
pub fn promote_to_uploaded(&mut self, name: &str, cid: String, did: String, format: String) {
156
156
+
self.pending.remove(name);
157
157
+
self.uploaded.insert(name.to_string(), (cid, did, format));
158
158
+
}
159
159
+
160
160
+
/// Add an already-uploaded image.
161
161
+
///
162
162
+
/// # Arguments
163
163
+
/// * `name` - The name/URL used in markdown (e.g., "photo.jpg")
164
164
+
/// * `cid` - The blob CID
165
165
+
/// * `did` - The DID of the blob owner
166
166
+
/// * `format` - The image format (e.g., "jpeg", "png")
167
167
+
pub fn add_uploaded(&mut self, name: String, cid: String, did: String, format: String) {
168
168
+
self.uploaded.insert(name, (cid, did, format));
169
169
+
}
170
170
+
171
171
+
/// Check if an image is pending upload.
172
172
+
pub fn is_pending(&self, name: &str) -> bool {
173
173
+
self.pending.contains_key(name)
174
174
+
}
175
175
+
176
176
+
/// Build a resolver from editor images and user DID.
177
177
+
pub fn from_images<'a>(
178
178
+
images: impl IntoIterator<Item = &'a super::document::EditorImage>,
179
179
+
user_did: &str,
180
180
+
) -> Self {
181
181
+
let mut resolver = Self::new();
182
182
+
for editor_image in images {
183
183
+
// Get the name from the Image (use alt text as fallback if name is empty)
184
184
+
let name = editor_image
185
185
+
.image
186
186
+
.name
187
187
+
.as_ref()
188
188
+
.map(|n| n.to_string())
189
189
+
.unwrap_or_else(|| editor_image.image.alt.to_string());
190
190
+
191
191
+
if name.is_empty() {
192
192
+
continue;
193
193
+
}
194
194
+
195
195
+
// Get CID and format from the blob ref
196
196
+
let blob = editor_image.image.image.blob();
197
197
+
let cid = blob.cid().to_string();
198
198
+
let format = blob
199
199
+
.mime_type
200
200
+
.0
201
201
+
.strip_prefix("image/")
202
202
+
.unwrap_or("jpeg")
203
203
+
.to_string();
204
204
+
205
205
+
resolver.add_uploaded(name, cid, user_did.to_string(), format);
206
206
+
}
207
207
+
resolver
208
208
+
}
209
209
+
}
210
210
+
211
211
+
impl ImageResolver for EditorImageResolver {
212
212
+
fn resolve_image_url(&self, url: &str) -> Option<String> {
213
213
+
// Extract image name from /image/{name} format
214
214
+
let name = url.strip_prefix("/image/").unwrap_or(url);
215
215
+
216
216
+
// Check pending first (data URL for immediate preview)
217
217
+
if let Some(data_url) = self.pending.get(name) {
218
218
+
return Some(data_url.clone());
219
219
+
}
220
220
+
221
221
+
// Then check uploaded (CDN URL)
222
222
+
let (cid, did, format) = self.uploaded.get(name)?;
223
223
+
Some(format!(
224
224
+
"https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
225
225
+
did, cid, format
226
226
+
))
227
227
+
}
228
228
+
}
229
229
+
230
230
+
impl ImageResolver for &EditorImageResolver {
231
231
+
fn resolve_image_url(&self, url: &str) -> Option<String> {
232
232
+
(*self).resolve_image_url(url)
233
233
+
}
234
234
+
}
235
235
+
106
236
/// HTML writer that preserves markdown formatting characters.
107
237
///
108
238
/// This writer processes offset-iter events to detect gaps (consumed formatting)
109
239
/// and emits them as styled spans for visibility in the editor.
110
110
-
pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = ()> {
240
240
+
pub struct EditorWriter<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E = (), R = ()> {
111
241
source: &'a str,
112
242
source_text: &'a LoroText,
113
243
events: I,
···
125
255
numbers: HashMap<String, usize>,
126
256
127
257
embed_provider: Option<E>,
258
258
+
image_resolver: Option<R>,
128
259
129
260
code_buffer: Option<(Option<String>, String)>, // (lang, content)
130
261
code_buffer_byte_range: Option<Range<usize>>, // byte range of buffered code content
···
172
303
Body,
173
304
}
174
305
175
175
-
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider>
176
176
-
EditorWriter<'a, I, W, E>
306
306
+
impl<
307
307
+
'a,
308
308
+
I: Iterator<Item = (Event<'a>, Range<usize>)>,
309
309
+
W: StrWrite,
310
310
+
E: EmbedContentProvider,
311
311
+
R: ImageResolver,
312
312
+
> EditorWriter<'a, I, W, E, R>
177
313
{
178
314
pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self {
179
315
Self::new_with_node_offset(source, source_text, events, writer, 0)
···
211
347
table_cell_index: 0,
212
348
numbers: HashMap::new(),
213
349
embed_provider: None,
350
350
+
image_resolver: None,
214
351
code_buffer: None,
215
352
code_buffer_byte_range: None,
216
353
code_buffer_char_range: None,
···
256
393
table_cell_index: 0,
257
394
numbers: HashMap::new(),
258
395
embed_provider: None,
396
396
+
image_resolver: None,
259
397
code_buffer: None,
260
398
code_buffer_byte_range: None,
261
399
code_buffer_char_range: None,
···
280
418
}
281
419
282
420
/// Add an embed content provider
283
283
-
pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E> {
421
421
+
pub fn with_embed_provider(self, provider: E) -> EditorWriter<'a, I, W, E, R> {
284
422
EditorWriter {
285
423
source: self.source,
286
424
source_text: self.source_text,
···
295
433
table_cell_index: self.table_cell_index,
296
434
numbers: self.numbers,
297
435
embed_provider: Some(provider),
436
436
+
image_resolver: self.image_resolver,
437
437
+
code_buffer: self.code_buffer,
438
438
+
code_buffer_byte_range: self.code_buffer_byte_range,
439
439
+
code_buffer_char_range: self.code_buffer_char_range,
440
440
+
pending_blockquote_range: self.pending_blockquote_range,
441
441
+
render_tables_as_markdown: self.render_tables_as_markdown,
442
442
+
table_start_offset: self.table_start_offset,
443
443
+
offset_maps: self.offset_maps,
444
444
+
next_node_id: self.next_node_id,
445
445
+
current_node_id: self.current_node_id,
446
446
+
current_node_char_offset: self.current_node_char_offset,
447
447
+
current_node_child_count: self.current_node_child_count,
448
448
+
utf16_checkpoints: self.utf16_checkpoints,
449
449
+
paragraph_ranges: self.paragraph_ranges,
450
450
+
current_paragraph_start: self.current_paragraph_start,
451
451
+
list_depth: self.list_depth,
452
452
+
boundary_only: self.boundary_only,
453
453
+
syntax_spans: self.syntax_spans,
454
454
+
next_syn_id: self.next_syn_id,
455
455
+
pending_inline_formats: self.pending_inline_formats,
456
456
+
_phantom: std::marker::PhantomData,
457
457
+
}
458
458
+
}
459
459
+
460
460
+
/// Add an image resolver for mapping markdown image URLs to CDN URLs
461
461
+
pub fn with_image_resolver<R2: ImageResolver>(
462
462
+
self,
463
463
+
resolver: R2,
464
464
+
) -> EditorWriter<'a, I, W, E, R2> {
465
465
+
EditorWriter {
466
466
+
source: self.source,
467
467
+
source_text: self.source_text,
468
468
+
events: self.events,
469
469
+
writer: self.writer,
470
470
+
last_byte_offset: self.last_byte_offset,
471
471
+
last_char_offset: self.last_char_offset,
472
472
+
end_newline: self.end_newline,
473
473
+
in_non_writing_block: self.in_non_writing_block,
474
474
+
table_state: self.table_state,
475
475
+
table_alignments: self.table_alignments,
476
476
+
table_cell_index: self.table_cell_index,
477
477
+
numbers: self.numbers,
478
478
+
embed_provider: self.embed_provider,
479
479
+
image_resolver: Some(resolver),
298
480
code_buffer: self.code_buffer,
299
481
code_buffer_byte_range: self.code_buffer_byte_range,
300
482
code_buffer_char_range: self.code_buffer_char_range,
···
1753
1935
}
1754
1936
1755
1937
self.write("<img src=\"")?;
1756
1756
-
escape_href(&mut self.writer, &dest_url)?;
1938
1938
+
// Try to resolve image URL via resolver, fall back to original
1939
1939
+
let resolved_url = self
1940
1940
+
.image_resolver
1941
1941
+
.as_ref()
1942
1942
+
.and_then(|r| r.resolve_image_url(&dest_url));
1943
1943
+
if let Some(ref cdn_url) = resolved_url {
1944
1944
+
escape_href(&mut self.writer, cdn_url)?;
1945
1945
+
} else {
1946
1946
+
escape_href(&mut self.writer, &dest_url)?;
1947
1947
+
}
1757
1948
self.write("\" alt=\"")?;
1758
1949
// Consume text events for alt attribute
1759
1950
self.raw_text()?;
···
2139
2330
}
2140
2331
}
2141
2332
2142
2142
-
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, W: StrWrite, E: EmbedContentProvider>
2143
2143
-
EditorWriter<'a, I, W, E>
2333
2333
+
impl<
2334
2334
+
'a,
2335
2335
+
I: Iterator<Item = (Event<'a>, Range<usize>)>,
2336
2336
+
W: StrWrite,
2337
2337
+
E: EmbedContentProvider,
2338
2338
+
R: ImageResolver,
2339
2339
+
> EditorWriter<'a, I, W, E, R>
2144
2340
{
2145
2341
fn write_embed(
2146
2342
&mut self,
+4
-4
crates/weaver-app/src/env.rs
···
1
1
// This file is automatically generated by build.rs
2
2
3
3
#[allow(unused)]
4
4
-
pub const WEAVER_APP_ENV: &'static str = "prod";
4
4
+
pub const WEAVER_APP_ENV: &'static str = "dev";
5
5
#[allow(unused)]
6
6
-
pub const WEAVER_APP_HOST: &'static str = "https://alpha.weaver.sh";
6
6
+
pub const WEAVER_APP_HOST: &'static str = "http://localhost";
7
7
#[allow(unused)]
8
8
-
pub const WEAVER_APP_DOMAIN: &'static str = "https://alpha.weaver.sh";
8
8
+
pub const WEAVER_APP_DOMAIN: &'static str = "";
9
9
#[allow(unused)]
10
10
pub const WEAVER_PORT: &'static str = "8080";
11
11
#[allow(unused)]
···
13
13
#[allow(unused)]
14
14
pub const WEAVER_CLIENT_NAME: &'static str = "Weaver";
15
15
#[allow(unused)]
16
16
-
pub const WEAVER_LOGO_URI: &'static str = "https://alpha.weaver.sh/favicon.ico";
16
16
+
pub const WEAVER_LOGO_URI: &'static str = "";
17
17
#[allow(unused)]
18
18
pub const WEAVER_TOS_URI: &'static str = "";
19
19
#[allow(unused)]