using Gtk; using Adw; using GtkCommonMark; namespace Publicate.Editors { public class MarkdownEditor : Box, EditorWidget, Savable { private Paned paned_view; private Box toolbar; private GtkCommonMark.MarkdownView? markdown_view = null; private GtkSource.View text_view; private GtkSource.Buffer source_buffer; private GtkSource.LanguageManager language_manager; private GtkSource.StyleSchemeManager style_manager; private ScrolledWindow source_scroller; private ScrolledWindow? markdown_scroller = null; private ViewerWindow window; private TabPage page; private DropDown style_selector; private ToggleButton bold_button; private ToggleButton italic_button; private Button insert_link; private Button insert_image; private ToggleButton preview_button; private const string STYLE_HEADING_1 = "Heading 1"; private const string STYLE_HEADING_2 = "Heading 2"; private const string STYLE_HEADING_3 = "Heading 3"; private const string STYLE_HEADING_4 = "Heading 4"; private const string STYLE_HEADING_5 = "Heading 5"; private const string STYLE_HEADING_6 = "Heading 6"; private const string STYLE_QUOTATION = "Quotation"; private const string STYLE_PREFORMATTED = "Preformatted"; private const string STYLE_PARAGRAPH = "Paragraph"; private Spelling.Checker spell_checker; private Spelling.TextBufferAdapter spell_adapter; private bool inhibit_toolbar_updates = false; private Gee.HashMap pixbuf_cache = new Gee.HashMap(); public Adw.TabPage tab_page { get { return page; } } public string asset_name { get{ return asset.name; } } private bool unsaved = false; public bool has_unsaved_changes { get{ return unsaved; } } private Ppub.Asset asset; public MarkdownEditor(ViewerWindow win, TabView tab_view) { window = win; orientation = Orientation.VERTICAL; source_scroller = new ScrolledWindow (); paned_view = new Paned(Orientation.HORIZONTAL); text_view = new GtkSource.View (); language_manager = new GtkSource.LanguageManager (); style_manager = new GtkSource.StyleSchemeManager(); text_view.monospace = true; text_view.show_line_numbers = true; text_view.auto_indent = true; source_buffer = (GtkSource.Buffer) text_view.buffer; language_manager.append_search_path("/usr/share/publicate/gtk-source-view"); source_buffer.language = language_manager.get_language("ppub-markdown"); text_view.hexpand = true; text_view.set_wrap_mode (WrapMode.WORD_CHAR); text_view.top_margin = 18; text_view.bottom_margin = 18; text_view.left_margin = 18; text_view.right_margin = 18; text_view.buffer.changed.connect (() => update_preview()); spell_checker = Spelling.Checker.get_default(); spell_adapter = new Spelling.TextBufferAdapter(source_buffer, spell_checker); text_view.set_extra_menu(spell_adapter.get_menu_model()); text_view.insert_action_group("spelling", spell_adapter); spell_adapter.set_enabled(true); source_scroller.set_policy (PolicyType.NEVER, PolicyType.AUTOMATIC); source_scroller.vexpand = true; source_scroller.vadjustment.value_changed.connect(sync_scroll); source_scroller.child = text_view; var source_box = new Box(Orientation.VERTICAL, 0); source_box.append(source_scroller); paned_view.start_child = source_box; Gtk.Settings.get_default().notify["gtk-application-prefer-dark-theme"].connect(() => configure_tags()); configure_tags(); toolbar = new Box(Orientation.HORIZONTAL, 8); toolbar.margin_start = 8; toolbar.margin_end = 8; toolbar.margin_top = 8; toolbar.margin_bottom = 8; style_selector = new DropDown.from_strings(new string[] {STYLE_HEADING_1, STYLE_HEADING_2, STYLE_HEADING_3, STYLE_HEADING_4, STYLE_HEADING_5, STYLE_HEADING_6, STYLE_QUOTATION, STYLE_PREFORMATTED, STYLE_PARAGRAPH}); toolbar.append(style_selector); bold_button = new ToggleButton(); bold_button.child = new Image.from_icon_name("format-text-bold-symbolic"); bold_button.tooltip_text = "Bold"; bold_button.clicked.connect(() => toggle_bold()); toolbar.append(bold_button); italic_button = new ToggleButton(); italic_button.child = new Image.from_icon_name("format-text-italic-symbolic"); italic_button.tooltip_text = "Italic"; italic_button.clicked.connect(() => toggle_italic()); toolbar.append(italic_button); insert_link = new Button.from_icon_name("insert-link-symbolic"); insert_link.tooltip_text = "Insert link"; insert_link.hexpand = true; insert_link.halign = Align.END; insert_link.clicked.connect(() => insert_link_clicked()); toolbar.append(insert_link); insert_image = new Button.from_icon_name("insert-image-symbolic"); insert_image.tooltip_text = "Insert image"; insert_image.clicked.connect(() => insert_image_clicked()); toolbar.append(insert_image); preview_button = new ToggleButton(); preview_button.child = new Image.from_icon_name("view-dual-symbolic"); preview_button.tooltip_text = "Markdown Preview"; preview_button.active = true; preview_button.clicked.connect(() => toggle_preview()); toolbar.append(preview_button); source_box.append(toolbar); source_buffer.cursor_moved.connect(() => update_toolbar()); source_buffer.highlight_updated.connect(() => update_toolbar()); append(paned_view); initialise_preview(); page = tab_view.add_page (this, null); } private void initialise_preview() { markdown_scroller = new ScrolledWindow (); markdown_scroller.set_policy (PolicyType.NEVER, PolicyType.AUTOMATIC); markdown_scroller.vexpand = true; markdown_view = new GtkCommonMark.MarkdownView (); markdown_view.hexpand = true; markdown_view.set_wrap_mode (WrapMode.WORD_CHAR); markdown_view.widget_embedded.connect(widget_embedded); markdown_scroller.child = markdown_view; // markdown_scroller.hadjustment.changed.connect(() => sync_scroll()); paned_view.end_child = markdown_scroller; set_leaflet_size(); configure_tags(); } private void destruct_preview() { paned_view.end_child = null; markdown_scroller = null; markdown_view = null; } public void set_zoom_percentage (int percent) { if(markdown_view != null) { var scale = (float)percent / 100f; markdown_view.tag_manager.font_scale = scale; } } public async void load_asset (Ppub.Publication publication, Ppub.Asset asset) { if(markdown_view != null) { markdown_view.buffer.set_text("", 0); } page.title = asset.name; this.asset = asset; text_view.buffer.set_text("", 0); MemoryOutputStream os = new MemoryOutputStream (null, GLib.realloc, GLib.free); yield os.splice_async (publication.read_asset (asset.name), OutputStreamSpliceFlags.CLOSE_SOURCE | OutputStreamSpliceFlags.CLOSE_TARGET); var text = os.steal_data (); text.length = (int) os.get_data_size (); text_view.buffer.set_text ((string)text, text.length); set_tab_name(false); Timeout.add_once(100, () => set_leaflet_size()); } private void set_leaflet_size() { paned_view.position = int.max(300, paned_view.get_width() / 2); } public void save_asset(Ppub.Builder builder) { var article_data = get_text().to_utf8(); var article_stream = new MemoryInputStream.from_data((uint8[])article_data); var compression_info = new Ppub.CompressionInfo(article_stream); article_stream.seek(0, SeekType.SET); builder.add_asset(asset.name, asset.mimetype, article_stream, Invercargill.Iterate.nothing(), compression_info); set_tab_name(false); } protected async void widget_embedded(GtkCommonMark.MarkdownViewEmbeddedWidgetHost widget, string file, string title) { var image = new Gtk.Picture(); image.content_fit = Gtk.ContentFit.FILL; widget.append (image); Gdk.Pixbuf pixbuf; if(pixbuf_cache.has_key(file)) { pixbuf = pixbuf_cache[file]; } else { pixbuf = yield new Gdk.Pixbuf.from_stream_async (window.publication.read_asset (file), null); pixbuf_cache.set(file, pixbuf); } image.set_pixbuf (pixbuf); widget.available_width_changed.connect(wid => { image.width_request = wid; var ratio = image.get_paintable().get_intrinsic_aspect_ratio (); image.height_request = (int)(wid / ratio); }); image.destroy.connect(() => print("IT gone\n")); } private void configure_tags() { var link = new LinkButton(""); if(markdown_view != null) { markdown_view.tag_manager.update_link_colour(link.get_color()); } if(Gtk.Settings.get_default().gtk_application_prefer_dark_theme) { source_buffer.style_scheme = style_manager.get_scheme("Adwaita-dark"); } else { source_buffer.style_scheme = style_manager.get_scheme("Adwaita"); } } private void update_preview(bool changes = true) { if(changes) { set_tab_name(true); } if(markdown_view == null) { return; } var text = get_text(); markdown_view.buffer.set_text("", 0); markdown_view.load_from_string((string)text); } public string get_text() { TextIter start_iter; TextIter end_iter; text_view.buffer.get_start_iter (out start_iter); text_view.buffer.get_end_iter (out end_iter); var text = text_view.buffer.get_text (start_iter, end_iter, true); return text; } private void sync_scroll() { if(markdown_scroller != null) { set_scroll_from_frac(markdown_scroller, get_scroll_frac(source_scroller)); } } private static double get_scroll_frac(ScrolledWindow scroller) { return (scroller.vadjustment.value/(scroller.vadjustment.upper - scroller.vadjustment.page_size)); } private void set_scroll_from_frac(ScrolledWindow scroller, double frac) { scroller.vadjustment.value = (scroller.vadjustment.upper - scroller.vadjustment.page_size)*frac; } private void set_tab_name(bool unsaved) { this.unsaved = unsaved; if(unsaved) { tab_page.title = @"⏺ $(asset.name)"; } else { tab_page.title = asset.name; } } private void update_toolbar() { if(inhibit_toolbar_updates) { print("Toolbar update inhibited\n"); return; } TextIter iter_start; TextIter iter_end; source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position); var multi_line = false; if(source_buffer.has_selection) { source_buffer.get_selection_bounds(out iter_start, out iter_end); multi_line = (iter_start.get_line() != iter_end.get_line()); } var classes = Invercargill.Wrap.array(source_buffer.get_context_classes_at_iter(iter_start)); // Disable formatting changes if the selection is over many lines or the "no-format" class is applied toolbar.sensitive = !multi_line && !classes.any(c => c == "no-format"); print(@"$(classes.to_string(s => s, " "))\n"); set_line_style_type_in_dropdown(get_line_style_from_buffer()); bold_button.active = classes.any(c => c == "b"); italic_button.active = classes.any(c => c == "i"); } private int get_line_style_from_buffer() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); if(iter.ends_line()) { iter.backward_char(); } var classes = Invercargill.Wrap.array(source_buffer.get_context_classes_at_iter(iter)); var heading = classes.first_or_default(c => c.has_prefix("h") && c.length == 2); if(heading != null) { var level = int.parse(heading[1:2]); return level - 1; } else if(classes.any(c => c == "quot")) { return 6; } else if(classes.any(c => c == "pre")) { return 7; } return 8; } private void remove_line_style() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); if(iter.ends_line()) { iter.backward_char(); } var classes = Invercargill.Wrap.array(source_buffer.get_context_classes_at_iter(iter)); var line_no = iter.get_line(); var heading = classes.first_or_default(c => c.has_prefix("h") && c.length == 2); if(heading != null) { var level = int.parse(heading[1:2]); source_buffer.get_iter_at_line(out iter, line_no); iter.forward_chars(level+1); for(int i = 0; i <= level; i++) { source_buffer.backspace(ref iter, false, true); } } else if(classes.any(c => c == "quot")) { source_buffer.get_iter_at_line(out iter, line_no); iter.forward_chars(2); source_buffer.backspace(ref iter, false, true); source_buffer.backspace(ref iter, false, true); } } private void set_line_style_type_in_dropdown(uint type) { style_selector.notify["selected"].disconnect(set_line_style); style_selector.selected = type; style_selector.notify["selected"].connect(set_line_style); } private void set_line_style() { var style = style_selector.selected; inhibit_toolbar_updates = true; source_buffer.begin_user_action(); remove_line_style(); print(@"Set style $style\n"); if(style < 6) { // Headings format_line_heading(style + 1); } else if(style == 6) { format_line_quote(); } else if(style == 7) { format_line_preformatted(); } set_line_style_type_in_dropdown(style); inhibit_toolbar_updates = false; source_buffer.end_user_action(); } private void format_line_heading(uint level) { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); var line_no = iter.get_line(); source_buffer.get_iter_at_line(out iter, line_no); for(var i = 0; i < level; i++) { source_buffer.insert(ref iter, "#", 1); } source_buffer.insert(ref iter, " ", 1); iter.forward_line(); iter.backward_char(); source_buffer.place_cursor(iter); } private void format_line_quote() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); var line_no = iter.get_line(); source_buffer.get_iter_at_line(out iter, line_no); source_buffer.insert(ref iter, "> ", 2); iter.forward_line(); iter.backward_char(); source_buffer.place_cursor(iter); } private void format_line_preformatted() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); var line_no = iter.get_line(); source_buffer.get_iter_at_line(out iter, line_no); source_buffer.insert(ref iter, "```\n", 4); iter.forward_line(); iter.backward_char(); source_buffer.insert(ref iter, "\n```", 4); iter.forward_line(); iter.backward_chars(5); source_buffer.place_cursor(iter); } private void format_text_bold() { TextIter iter_start; TextIter iter_end; if(source_buffer.has_selection) { source_buffer.get_selection_bounds(out iter_start, out iter_end); } else { source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position); source_buffer.get_iter_at_offset(out iter_end, source_buffer.cursor_position); if(!iter_start.starts_word()) { iter_start.backward_word_start(); } if(!iter_end.ends_word()) { iter_end.forward_word_end(); } } var difference = iter_end.get_offset() - iter_start.get_offset(); source_buffer.insert(ref iter_start, "**", 2); iter_start.forward_chars(difference); source_buffer.insert(ref iter_start, "**", 2); } private void format_text_unbold() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); source_buffer.iter_forward_to_context_class_toggle(ref iter, "b"); var end_pos = iter.get_offset(); source_buffer.iter_backward_to_context_class_toggle(ref iter, "b"); iter.forward_chars(2); source_buffer.backspace(ref iter, false, true); source_buffer.backspace(ref iter, false, true); iter.set_offset(end_pos-2); source_buffer.backspace(ref iter, false, true); source_buffer.backspace(ref iter, false, true); } private void toggle_bold() { source_buffer.begin_user_action(); if(!bold_button.active) { format_text_unbold(); } else { format_text_bold(); } source_buffer.end_user_action(); } private void format_text_italic() { TextIter iter_start; TextIter iter_end; if(source_buffer.has_selection) { source_buffer.get_selection_bounds(out iter_start, out iter_end); } else { source_buffer.get_iter_at_offset(out iter_start, source_buffer.cursor_position); source_buffer.get_iter_at_offset(out iter_end, source_buffer.cursor_position); if(!iter_start.starts_word()) { iter_start.backward_word_start(); } if(!iter_end.ends_word()) { iter_end.forward_word_end(); } } var difference = iter_end.get_offset() - iter_start.get_offset(); source_buffer.insert(ref iter_start, "*", 1); iter_start.forward_chars(difference); source_buffer.insert(ref iter_start, "*", 1); } private void format_text_unitalic() { TextIter iter; source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); source_buffer.iter_forward_to_context_class_toggle(ref iter, "i"); var end_pos = iter.get_offset(); source_buffer.iter_backward_to_context_class_toggle(ref iter, "i"); iter.forward_chars(1); source_buffer.backspace(ref iter, false, true); iter.set_offset(end_pos-1); source_buffer.backspace(ref iter, false, true); } private void toggle_italic() { source_buffer.begin_user_action(); if(!italic_button.active) { format_text_unitalic(); } else { format_text_italic(); } source_buffer.end_user_action(); } private void insert_link_clicked() { string selected = null; if(source_buffer.has_selection) { TextIter iter_start; TextIter iter_end; source_buffer.get_selection_bounds(out iter_start, out iter_end); selected = iter_start.get_text(iter_end); } var win = new AddLinkWindow(selected); win.insert_link.connect(link_insert); win.set_transient_for(window); win.modal = true; win.present(); } private void link_insert(string url, string? label) { TextIter iter; source_buffer.begin_user_action(); if(source_buffer.has_selection) { TextIter iter_end; source_buffer.get_selection_bounds(out iter, out iter_end); source_buffer.delete(ref iter, ref iter_end); } else { source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); } var link = ""; if(label != null) { link = @"[$label]($url)"; } else { link = @"<$url>"; } source_buffer.insert(ref iter, link, link.length); source_buffer.end_user_action(); } private void insert_image_clicked() { string selected = null; if(source_buffer.has_selection) { TextIter iter_start; TextIter iter_end; source_buffer.get_selection_bounds(out iter_start, out iter_end); selected = iter_start.get_text(iter_end); } var win = new AddImageWindow(selected, window); win.insert_image.connect(image_insert); win.set_transient_for(window); win.modal = true; win.present(); } private void image_insert(string url, string label) { TextIter iter; source_buffer.begin_user_action(); if(source_buffer.has_selection) { TextIter iter_end; source_buffer.get_selection_bounds(out iter, out iter_end); source_buffer.delete(ref iter, ref iter_end); } else { source_buffer.get_iter_at_offset(out iter, source_buffer.cursor_position); } var link = @"![$label]($url)"; source_buffer.insert(ref iter, link, link.length); source_buffer.end_user_action(); } private void toggle_preview() { if(preview_button.active) { initialise_preview(); update_preview(false); } else { destruct_preview(); } } } public class AddLinkWindow : Adw.Window { public signal void insert_link(string url, string? text); private EntryRow url_entry; private EntryRow text_entry; public AddLinkWindow(string? text) { title = "Insert link"; var box = new Box(Orientation.VERTICAL, 18); var header = new Adw.HeaderBar(); header.show_end_title_buttons = false; box.append(header); var insert_button = new Button.with_label("Insert"); var cancel_button = new Button.with_label("Cancel"); insert_button.add_css_class("suggested-action"); insert_button.clicked.connect(() => insert()); cancel_button.clicked.connect(() => close()); header.pack_start(cancel_button); header.pack_end(insert_button); var group = new PreferencesGroup(); group.width_request = 400; group.title = "Link properties"; group.margin_bottom = 18; group.margin_end = 18; group.margin_start = 18; url_entry = new EntryRow (); url_entry.title = "URI"; group.add(url_entry); text_entry = new EntryRow (); text_entry.title = "Label (optional)"; group.add(text_entry); if(text != null) { try { Uri.is_valid(text, UriFlags.NONE); url_entry.text = text; } catch (Error e) { text_entry.text = text; } } box.append(group); content = box; } private void insert() { string text = null; if(text_entry.text.chomp().chug() != "" || text_entry.text == url_entry.text) { text = text_entry.text; } insert_link(url_entry.text, text); close(); } } public class AddImageWindow : Adw.Window { public signal void insert_image(string url, string text); private FileChooserRow file_entry; private EntryRow text_entry; public AddImageWindow(string? text, ViewerWindow win) { title = "Insert link"; var box = new Box(Orientation.VERTICAL, 18); var header = new Adw.HeaderBar(); header.show_end_title_buttons = false; box.append(header); var insert_button = new Button.with_label("Insert"); var cancel_button = new Button.with_label("Cancel"); insert_button.add_css_class("suggested-action"); insert_button.clicked.connect(() => insert()); cancel_button.clicked.connect(() => close()); header.pack_start(cancel_button); header.pack_end(insert_button); var group = new PreferencesGroup(); group.width_request = 400; group.title = "Link properties"; group.margin_bottom = 18; group.margin_end = 18; group.margin_start = 18; file_entry = new FileChooserRow(win); file_entry.title = "Image file"; file_entry.set_assets(win.publication.assets.where(a => a.mimetype.has_prefix ("image/"))); group.add(file_entry); text_entry = new EntryRow (); text_entry.title = "Image description"; group.add(text_entry); if(text != null) { text_entry.text = text; } box.append(group); content = box; } private void insert() { insert_image(file_entry.selected_asset, text_entry.text); close(); } } }