├── MarkdownView.py └── readme.md /MarkdownView.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | #!python2 3 | 4 | import ui 5 | from markdown2 import markdown 6 | from urllib import quote, unquote 7 | import clipboard 8 | import webbrowser 9 | from string import Template 10 | #from RootView import RootView 11 | from objc_util import ObjCClass, ObjCInstance, sel, on_main_thread 12 | 13 | class MarkdownView(ui.View): 14 | 15 | def __init__(self, frame = None, flex = None, background_color = None, name = None, accessory_keys = True, extras = [], css = None): 16 | 17 | if frame: self.frame = frame 18 | if flex: self.flex = flex 19 | if background_color: self.background_color = background_color 20 | if name: self.name = name 21 | 22 | self.extras = extras 23 | self.css = css or self.default_css 24 | self.proxy_delegate = None 25 | 26 | self.enable_links = True 27 | self.editing = False 28 | self.margins = (10, 10, 10, 10) 29 | 30 | self.link_prefix = 'pythonista-markdownview:relay?content=' 31 | self.debug_prefix = 'pythonista-markdownview:debug?content=' 32 | self.init_postfix = '#pythonista-markdownview-initialize' 33 | self.in_doc_prefix = '' 34 | 35 | self.to_add_to_beginning = ('', -1) 36 | 37 | self.backpanel = ui.View() 38 | self.add_subview(self.backpanel) 39 | 40 | # Web fragment is used to find the right scroll position when moving from editing to viewing 41 | self.web_fragment = ui.WebView() 42 | self.web_fragment.hidden = True 43 | self.web_fragment.delegate = MarkdownView.ScrollLoadDelegate() 44 | self.add_subview(self.web_fragment) 45 | 46 | self.markup = ui.TextView() 47 | self.add_subview(self.markup) 48 | self.markup.font = ('', 12) 49 | 50 | self.web = ui.WebView() 51 | self.web.scales_page_to_fit = False 52 | self.web.content_mode = ui.CONTENT_TOP_LEFT 53 | self.add_subview(self.web) 54 | 55 | self.web.delegate = self 56 | self.markup.delegate = self 57 | 58 | self.markup.text = '' 59 | self.update_html() 60 | self.markup.bounces = False 61 | 62 | # Ghosts are used to determine preferred size 63 | self.markup_ghost = ui.TextView() 64 | self.markup_ghost.hidden = True 65 | #self.add_subview(self.markup_ghost) 66 | self.web_ghost = ui.WebView() 67 | self.web_ghost.hidden = True 68 | #self.add_subview(self.web_ghost) 69 | 70 | if accessory_keys: 71 | self.create_accessory_toolbar() 72 | 73 | htmlIntro = Template(''' 74 | 75 | 76 | 77 | Markdown 78 | 81 | 135 | 136 | 137 | 138 | 139 |
140 | ''') 141 | htmlOutro = ''' 142 |
143 | 144 | 145 | ''' 146 | default_css = ''' 147 | * { 148 | font-size: $font_size; 149 | font-family: $font_family; 150 | color: $text_color; 151 | text-align: $text_align; 152 | -webkit-text-size-adjust: none; 153 | -webkit-tap-highlight-color: transparent; 154 | } 155 | h1 { 156 | font-size: larger; 157 | } 158 | h3 { 159 | font-style: italic; 160 | } 161 | h4 { 162 | font-weight: normal; 163 | font-style: italic; 164 | } 165 | code { 166 | font-family: monospace; 167 | } 168 | li { 169 | margin: .4em 0; 170 | } 171 | body { 172 | #line-height: 1; 173 | background: $background_color; 174 | } 175 | ''' 176 | 177 | def to_html(self, md = None, scroll_pos = -1, content_only = False): 178 | md = md or self.markup.text 179 | result = markdown(md, extras=self.extras) 180 | if not content_only: 181 | intro = Template(self.htmlIntro.safe_substitute(css = self.css)) 182 | (font_name, font_size) = self.font 183 | result = intro.safe_substitute( 184 | background_color = self.to_css_rgba(self.markup.background_color), 185 | text_color = self.to_css_rgba(self.markup.text_color), 186 | font_family = font_name, 187 | text_align = self.to_css_alignment(), 188 | font_size = str(font_size)+'px', 189 | init_postfix = self.init_postfix, 190 | link_prefix = self.link_prefix, 191 | debug_prefix = self.debug_prefix, 192 | scroll_pos = scroll_pos 193 | ) + result + self.htmlOutro 194 | return result 195 | 196 | def to_css_rgba(self, color): 197 | return 'rgba({:.0f},{:.0f},{:.0f},{})'.format(color[0]*255, color[1]*255, color[2]*255, color[3]) 198 | 199 | def to_css_alignment(self): 200 | mapping = { ui.ALIGN_LEFT: 'left', ui.ALIGN_CENTER: 'center', ui.ALIGN_RIGHT: 'right', ui.ALIGN_JUSTIFIED: 'justify', ui.ALIGN_NATURAL: 'start' } 201 | return mapping[self.markup.alignment] 202 | 203 | def update_html(self): 204 | html = self.to_html(self.markup.text, 0) 205 | self.web.load_html(html) 206 | html_ghost = self.to_html(self.markup.text) 207 | self.web_fragment.load_html(html_ghost) 208 | 209 | '''ACCESSORY TOOLBAR''' 210 | @on_main_thread 211 | def create_accessory_toolbar(self): 212 | from objc_util import ObjCClass, ObjCInstance, sel 213 | 214 | def create_button(label, func): 215 | button_width = 25 216 | black = ObjCClass('UIColor').alloc().initWithWhite_alpha_(0.0, 1.0) 217 | action_button = ui.Button() 218 | action_button.action = func 219 | accessory_button = ObjCClass('UIBarButtonItem').alloc().initWithTitle_style_target_action_(label, 0, action_button, sel('invokeAction:')) 220 | accessory_button.width = button_width 221 | accessory_button.tintColor = black 222 | return (action_button, accessory_button) 223 | 224 | vobj = ObjCInstance(self.markup) 225 | 226 | keyboardToolbar = ObjCClass('UIToolbar').alloc().init() 227 | 228 | keyboardToolbar.sizeToFit() 229 | 230 | button_width = 25 231 | black = ObjCClass('UIColor').alloc().initWithWhite_alpha_(0.0, 1.0) 232 | 233 | # Create the buttons 234 | # Need to retain references to the buttons used 235 | # to handle clicks 236 | (self.indentButton, indentBarButton) = create_button(u'\u21E5', self.indent) 237 | 238 | (self.outdentButton, outdentBarButton) = create_button(u'\u21E4', self.outdent) 239 | 240 | (self.quoteButton, quoteBarButton) = create_button('>', self.block_quote) 241 | 242 | (self.linkButton, linkBarButton) = create_button('[]', self.link) 243 | 244 | #(self.anchorButton, anchorBarButton) = create_button('<>', self.anchor) 245 | 246 | (self.hashButton, hashBarButton) = create_button('#', self.heading) 247 | 248 | (self.numberedButton, numberedBarButton) = create_button('1.', self.numbered_list) 249 | 250 | (self.listButton, listBarButton) = create_button('•', self.unordered_list) 251 | 252 | (self.underscoreButton, underscoreBarButton) = create_button('_', self.insert_underscore) 253 | 254 | (self.backtickButton, backtickBarButton) = create_button('`', self.insert_backtick) 255 | 256 | # Flex between buttons 257 | f = ObjCClass('UIBarButtonItem').alloc().initWithBarButtonSystemItem_target_action_(5, None, None) 258 | 259 | doneBarButton = ObjCClass('UIBarButtonItem').alloc().initWithBarButtonSystemItem_target_action_(0, vobj, sel('endEditing:')) 260 | 261 | keyboardToolbar.items = [indentBarButton, f, outdentBarButton, f, quoteBarButton, f, linkBarButton, f, hashBarButton, f, numberedBarButton, f, listBarButton, f, underscoreBarButton, f, backtickBarButton, f, doneBarButton] 262 | vobj.inputAccessoryView = keyboardToolbar 263 | 264 | def indent(self, sender): 265 | def func(line): 266 | return ' ' + line 267 | self.transform_lines(func) 268 | 269 | def outdent(self, sender): 270 | def func(line): 271 | if str(line).startswith(' '): 272 | return line[2:] 273 | self.transform_lines(func, ignore_spaces = False) 274 | 275 | def insert_underscore(self, sender): 276 | self.insert_character('_', '___') 277 | 278 | def insert_backtick(self, sender): 279 | self.insert_character('`', '`') 280 | 281 | def insert_character(self, to_insert, to_remove): 282 | tv = self.markup 283 | (start, end) = tv.selected_range 284 | (r_start, r_end) = (start, end) 285 | r_len = len(to_remove) 286 | if start != end: 287 | if tv.text[start:end].startswith(to_remove): 288 | if end - start > 2*r_len + 1 and tv.text[start:end].endswith(to_remove): 289 | to_insert = tv.text[start+r_len:end-r_len] 290 | r_end = end-2*r_len 291 | elif start-r_len > 0 and tv.text[start-r_len:end].startswith(to_remove): 292 | if end+r_len <= len(tv.text) and tv.text[start:end+r_len].endswith(to_remove): 293 | to_insert = tv.text[start:end] 294 | start -= r_len 295 | end += r_len 296 | r_start = start 297 | r_end = end-2*r_len 298 | else: 299 | r_end = end + 2*len(to_insert) 300 | to_insert = to_insert + tv.text[start:end] + to_insert 301 | tv.replace_range((start, end), to_insert) 302 | if start != end: 303 | tv.selected_range = (r_start, r_end) 304 | 305 | def heading(self, sender): 306 | def func(line): 307 | return line[3:] if str(line).startswith('###') else '#' + line 308 | self.transform_lines(func, ignore_spaces = False) 309 | 310 | def numbered_list(self, data): 311 | def func(line): 312 | if line.startswith('1. '): 313 | return line[3:] 314 | else: 315 | return '1. ' + (line[2:] if line.startswith('* ') else line) 316 | self.transform_lines(func) 317 | 318 | def unordered_list(self, sender): 319 | def func(line): 320 | if str(line).startswith('* '): 321 | return line[2:] 322 | else: 323 | return '* ' + (line[3:] if line.startswith('1. ') else line) 324 | self.transform_lines(func) 325 | 326 | def block_quote(self, sender): 327 | def func(line): 328 | return '> ' + line 329 | self.transform_lines(func, ignore_spaces = False) 330 | 331 | def link(self, sender): 332 | templ = "[#]($)" 333 | (start, end) = self.markup.selected_range 334 | templ = templ.replace('$', self.markup.text[start:end]) 335 | new_start = start + templ.find('#') 336 | new_end = new_start + (end - start) 337 | templ = templ.replace('#', self.markup.text[start:end]) 338 | self.markup.replace_range((start, end), templ) 339 | self.markup.selected_range = (new_start, new_end) 340 | 341 | ''' 342 | OBSOLETE: Use heading-ids or toc markdown extra instead 343 | 344 | * __<>__ - In-document links - Creates an anchor (`` tag) after the selection, assumed to be some heading text. At the same time, places a link to the anchor on the clipboard, typically to be pasted in a table of contents. 345 | ''' 346 | def anchor(self, sender): 347 | templ = " " 348 | (start, end) = self.markup.selected_range 349 | link_label = self.markup.text[start:end] 350 | link_name = quote(self.markup.text[start:end]) 351 | templ = templ.replace('#', link_name) 352 | self.markup.replace_range((end, end), templ) 353 | link = "[" + link_label + "](#" + link_name + ")" 354 | clipboard.set(link) 355 | 356 | def make_list(self, list_marker): 357 | self.get_lines() 358 | 359 | def transform_lines(self, func, ignore_spaces = True): 360 | (orig_start, orig_end) = self.markup.selected_range 361 | (lines, start, end) = self.get_lines() 362 | replacement = [] 363 | for line in lines: 364 | spaces = '' 365 | if ignore_spaces: 366 | space_count = len(line) - len(line.lstrip(' ')) 367 | if space_count > 0: 368 | spaces = line[:space_count] 369 | line = line[space_count:] 370 | replacement.append(spaces + func(line)) 371 | self.markup.replace_range((start, end), '\n'.join(replacement)) 372 | new_start = orig_start + len(replacement[0]) - len(lines[0]) 373 | if new_start < start: 374 | new_start = start 375 | end_displacement = 0 376 | for index, line in enumerate(lines): 377 | end_displacement += len(replacement[index]) - len(line) 378 | new_end = orig_end + end_displacement 379 | if new_end < new_start: 380 | new_end = new_start 381 | self.markup.selected_range = (new_start, new_end) 382 | 383 | def get_lines(self): 384 | (start, end) = self.markup.selected_range 385 | text = self.markup.text 386 | new_start = text.rfind('\n', 0, start) 387 | new_start = 0 if new_start == -1 else new_start + 1 388 | new_end = text.find('\n', end) 389 | if new_end == -1: new_end = len(text) 390 | #else: new_end -= 1 391 | if new_end < new_start: new_end = new_start 392 | return (text[new_start:new_end].split('\n'), new_start, new_end) 393 | 394 | def layout(self): 395 | (top, left, bottom, right) = self.margins 396 | self.backpanel.frame = (0, 0, self.width, self.height) 397 | self.markup.frame = (left, top, self.width - left - right, self.height - top - bottom) 398 | self.web.frame = self.markup.frame 399 | 400 | def start_editing(self, words): 401 | if not self.markup.editable: 402 | return 403 | self.web.hidden = True 404 | self.editing = True 405 | caret_pos = 0 406 | marked = self.markup.text 407 | for word in words: 408 | caret_pos = marked.find(word, caret_pos) + len(word) 409 | self.markup.begin_editing() 410 | self.markup.selected_range = (caret_pos, caret_pos) 411 | 412 | '''VIEW PROXY PROPERTIES''' 413 | 414 | @property 415 | def background_color(self): 416 | return self.markup.background_color 417 | @background_color.setter 418 | def background_color(self, value): 419 | self.markup.background_color = value 420 | self.backpanel.background_color = value 421 | #self.background_color = value 422 | self.update_html() 423 | 424 | '''SIZING METHODS''' 425 | 426 | 427 | def size_to_fit(self, using='current', min_width=None, max_width=None, min_height=None, max_height=None): 428 | (self.width, self.height) = self.preferred_size(using) 429 | 430 | def preferred_size(self, using='current', min_width=None, max_width=None, min_height=None, max_height=None): 431 | 432 | if using=='current': 433 | using = 'markdown' if self.editing else 'html' 434 | 435 | if using=='markdown': 436 | self.markup_ghost.text = self.markup.text 437 | view = self.markup_ghost 438 | else: 439 | view = self.web_ghost 440 | 441 | view.size_to_fit() 442 | if max_width and view.width > max_width: 443 | view.width = max_width 444 | view.size_to_fit() 445 | if max_width and view.width > max_width: 446 | view.width = max_width 447 | if min_width and view.width < min_width: 448 | view.width = min_width 449 | if max_height and view.height > max_height: 450 | view.height = max_height 451 | if min_height and view.height < min_height: 452 | view.height = min_height 453 | 454 | return (view.width, view.height) 455 | 456 | 457 | '''TEXTVIEW PROXY PROPERTIES''' 458 | 459 | @property 460 | def alignment(self): 461 | return self.markup.alignment 462 | @alignment.setter 463 | def alignment(self, value): 464 | self.markup.alignment = value 465 | self.update_html() 466 | 467 | @property 468 | def autocapitalization_type(self): 469 | return self.markup.autocapitalization_type 470 | @autocapitalization_type.setter 471 | def autocapitalization_type(self, value): 472 | self.markup.autocapitalization_type = value 473 | 474 | @property 475 | def autocorrection_type(self): 476 | return self.markup.autocorrection_type 477 | @autocorrection_type.setter 478 | def autocorrection_type(self, value): 479 | self.markup.autocorrection_type = value 480 | 481 | @property 482 | def auto_content_inset(self): 483 | return self.markup.auto_content_inset 484 | @auto_content_inset.setter 485 | def auto_content_inset(self, value): 486 | self.markup.auto_content_inset = value 487 | 488 | @property 489 | def delegate(self): 490 | return self.proxy_delegate 491 | @delegate.setter 492 | def delegate(self, value): 493 | self.proxy_delegate = value 494 | 495 | @property 496 | def editable(self): 497 | return self.markup.editable 498 | @editable.setter 499 | def editable(self, value): 500 | self.markup.editable = value 501 | 502 | @property 503 | def font(self): 504 | return self.markup.font 505 | @font.setter 506 | def font(self, value): 507 | self.markup.font = value 508 | self.update_html() 509 | 510 | @property 511 | def keyboard_type(self): 512 | return self.markup.keyboard_type 513 | @keyboard_type.setter 514 | def keyboard_type(self, value): 515 | self.markup.keyboard_type = value 516 | 517 | @property 518 | def selectable(self): 519 | return self.markup.selectable 520 | @selectable.setter 521 | def selectable(self, value): 522 | self.markup.selectable = value 523 | 524 | @property 525 | def selected_range(self): 526 | return self.markup.selected_range 527 | @selected_range.setter 528 | def selected_range(self, value): 529 | self.markup.selected_range = value 530 | 531 | @property 532 | def spellchecking_type(self): 533 | return self.markup.spellchecking_type 534 | @spellchecking_type.setter 535 | def spellchecking_type(self, value): 536 | self.markup.spellchecking_type = value 537 | 538 | @property 539 | def text(self): 540 | return self.markup.text 541 | @text.setter 542 | def text(self, value): 543 | self.markup.text = value 544 | self.update_html() 545 | 546 | @property 547 | def text_color(self): 548 | return self.markup.text_color 549 | @text_color.setter 550 | def text_color(self, value): 551 | self.markup.text_color = value 552 | self.update_html() 553 | 554 | '''TEXTVIEW PROXY METHODS''' 555 | 556 | def replace_range(self, range, text): 557 | self.markup.replace_range(range, text) 558 | self.update_html() 559 | 560 | '''WEBVIEW PROXY PROPERTIES''' 561 | 562 | @property 563 | def scales_page_to_fit(self): 564 | return self.web.scales_page_to_fit 565 | @scales_page_to_fit.setter 566 | def scales_page_to_fit(self, value): 567 | self.web.scales_page_to_fit = value 568 | 569 | #def keyboard_frame_did_change(self, frame): 570 | #if self.custom_keys: 571 | #self.custom_keys.change(frame, self.markup) 572 | 573 | def can_call(self, func_name): 574 | if not self.proxy_delegate: 575 | return False 576 | return callable(getattr(self.proxy_delegate, func_name, None)) 577 | 578 | '''TEXTVIEW DELEGATES''' 579 | def textview_did_end_editing(self, textview): 580 | (start, end) = self.markup.selected_range 581 | html_fragment = self.to_html(self.markup.text[0:start]) 582 | self.web_fragment.frame = self.web.frame 583 | self.web_fragment.delegate.end_edit = True 584 | self.web_fragment.load_html(html_fragment) 585 | 586 | class ScrollLoadDelegate(): 587 | def __init__(self): 588 | self.end_edit = False 589 | def webview_did_finish_load(self, webview): 590 | if not self.end_edit: return 591 | self.end_edit = False 592 | 593 | m = webview.superview 594 | scroll_pos = int(webview.eval_js('document.getElementById("content").clientHeight')) 595 | html = m.to_html(m.markup.text, scroll_pos) 596 | m.web.load_html(html) 597 | html_ghost = m.to_html(m.markup.text) 598 | m.web_ghost.load_html(html_ghost) 599 | #m.web.hidden = False 600 | m.editing = False 601 | if m.can_call('textview_did_end_editing'): 602 | m.proxy_delegate.textview_did_end_editing(textview) 603 | 604 | def textview_should_change(self, textview, range, replacement): 605 | should_change = True 606 | self.to_add_to_beginning = ('', -1) 607 | if self.can_call('textview_should_change'): 608 | should_change = self.proxy_delegate.textview_should_change(textview, range, replacement) 609 | if should_change == True and replacement == '\n': #and range[0] == range[1] 610 | pos = range[0] 611 | next_line_prefix = '' 612 | # Get to next line 613 | pos = self.markup.text.rfind('\n', 0, pos) 614 | if not pos == -1: 615 | pos = pos + 1 616 | rest = self.markup.text[pos:] 617 | # Copy leading spaces 618 | space_count = len(rest) - len(rest.lstrip(' ')) 619 | if space_count > 0: 620 | next_line_prefix += rest[:space_count] 621 | rest = rest[space_count:] 622 | # Check for prefixes 623 | prefixes = [ '1. ', '+ ', '- ', '* '] 624 | for prefix in prefixes: 625 | if rest.startswith(prefix): 626 | next_line_prefix += prefix 627 | break 628 | if len(next_line_prefix) > 0: 629 | diff = range[0] - pos 630 | if diff < len(next_line_prefix): 631 | next_line_prefix = next_line_prefix[:diff] 632 | self.to_add_to_beginning = (next_line_prefix, range[0]+1) 633 | return should_change 634 | 635 | def textview_did_change(self, textview): 636 | add = self.to_add_to_beginning 637 | if add[1] > -1: 638 | self.to_add_to_beginning = ('', -1) 639 | self.markup.replace_range((add[1], add[1]), add[0]) 640 | if self.can_call('textview_did_change'): 641 | self.proxy_delegate.textview_did_change(textview) 642 | 643 | def textview_should_begin_editing(self, textview): 644 | if self.can_call('textview_should_begin_editing'): 645 | return self.proxy_delegate.textview_should_begin_editing(textview) 646 | else: 647 | return True 648 | def textview_did_begin_editing(self, textview): 649 | if self.can_call('textview_did_begin_editing'): 650 | self.proxy_delegate.textview_did_begin_editing(textview) 651 | 652 | def textview_did_change_selection(self, textview): 653 | if self.can_call('textview_did_change_selection'): 654 | self.proxy_delegate.textview_did_change_selection(textview) 655 | 656 | '''WEBVIEW DELEGATES''' 657 | 658 | def webview_should_start_load(self, webview, url, nav_type): 659 | # Click, should start edit in markdown 660 | if url.startswith(self.link_prefix): 661 | left_side = unquote(url.replace(self.link_prefix, '')) 662 | self.start_editing(left_side.split()) 663 | #webview.stop() 664 | return False 665 | # Debug message from web page, print to console 666 | elif url.startswith(self.debug_prefix): 667 | debug_text = unquote(url.replace(self.debug_prefix, '')) 668 | print debug_text 669 | return False 670 | # Loaded by the web view at start, allow 671 | elif url.startswith('about:blank'): 672 | return True 673 | # Custom WebView initialization message 674 | # Used to check if in-doc links starting with '#' 675 | # have extra stuff in front 676 | elif url.endswith(self.init_postfix): 677 | self.in_doc_prefix = url[:len(url)-len(self.init_postfix)] 678 | self.web.hidden = False 679 | return False 680 | 681 | # If link starts with the extra stuff detected 682 | # at initialization, remove the extra 683 | if url.startswith(self.in_doc_prefix): 684 | url = url[len(self.in_doc_prefix):] 685 | 686 | # Check for custom link handling 687 | if self.can_call('webview_should_start_load'): 688 | return self.proxy_delegate.webview_should_start_load(webview, url, nav_type) 689 | # Handle in-doc links within the page 690 | elif url.startswith('#'): 691 | if self.can_call('webview_should_load_internal_link'): 692 | return self.proxy_delegate.webview_should_load_internal_link(webview, url) 693 | return True 694 | # Open 'http(s)' links in Safari 695 | # 'file' in built-in browser 696 | # Others like 'twitter' as OS decides 697 | else: 698 | if self.can_call('webview_should_load_external_link'): 699 | return self.proxy_delegate.webview_should_load_external_link(webview, url) 700 | if url.startswith('http:') or url.startswith('https:'): 701 | url = 'safari-' + url 702 | webbrowser.open(url) 703 | return False 704 | 705 | def webview_did_start_load(self, webview): 706 | if self.can_call('webview_did_start_load'): 707 | self.proxy_delegate.webview_did_start_load(webview) 708 | def webview_did_finish_load(self, webview): 709 | #if not self.editing: 710 | #self.web.hidden = False 711 | if self.can_call('webview_did_finish_load'): 712 | self.proxy_delegate.webview_did_finish_load(webview) 713 | def webview_did_fail_load(self, webview, error_code, error_msg): 714 | if self.can_call('webview_did_fail_load'): 715 | self.proxy_delegate.webview_did_fail_load(webview, error_code, error_msg) 716 | 717 | ### CUT HERE - everything below this line is for demonstration only and can be removed for production 718 | 719 | if __name__ == "__main__": 720 | import os 721 | readme_filename = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'readme.md') 722 | class SaveDelegate (object): 723 | def textview_did_change(self, textview): 724 | with open(readme_filename, "w") as file_out: 725 | file_out.write(textview.text) 726 | init_string = '' 727 | 728 | markdown_edit = MarkdownView(extras = ["header-ids"]) 729 | # Use this if you do not want accessory keys: 730 | #markdown_edit = MarkdownView(accessory_keys = False) 731 | markdown_edit.name = 'MarkdownView Documentation' 732 | 733 | if os.path.exists(readme_filename): 734 | with open(readme_filename) as file_in: 735 | init_string = file_in.read() 736 | markdown_edit.text = init_string 737 | markdown_edit.delegate = SaveDelegate() 738 | 739 | markdown_edit.font = ('Apple SD Gothic Neo', 16) 740 | markdown_edit.background_color = '#f7f9ff' 741 | markdown_edit.text_color = '#030b60' 742 | markdown_edit.margins = (10, 10, 10, 10) 743 | 744 | # Examples of other attributes to set: 745 | #markdown_edit.alignment = ui.ALIGN_JUSTIFIED 746 | #markdown_edit.editable = False 747 | 748 | markdown_edit.present(style='fullscreen', hide_title_bar=False) 749 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # MarkdownView 2 | 3 | MarkdownView is a [Pythonista](http://omz-software.com/pythonista/) UI library component. It is a drop-in replacement for ui.TextView that supports both editing [markdown](https://daringfireball.net/projects/markdown/) tagged text and viewing it as HTML. 4 | 5 | ![Demo](https://espq1q.by3301.livefilestore.com/y3mRxyyKwIANcZia4VSQ5SBJfFFlZsCb-qoReBY49SXjkdYFjhlRCI6btZ7dWxlqBwHMDM9oFqD34rj9Q1rzhgqqPraNV0jji0XjxN4ee2-md8CHmcqkjVsQ1Z-eohQNQ6LD5hNJSztGKmOcUKTWdwzWwYpTwG6sa5GAPMLQLGBn4Y/20151222_081323000_iOS.gif?psid=1) 6 | 7 | ##Contents 8 | 9 | 1. [Features](#features) 10 | 1. [Quick start](#quick-start) 11 | 1. [Link behavior](#link-behavior) 12 | 1. [Additional keys](#additional-keys) 13 | 1. [Component motivation and design principles](#component-motivation-and-design-principles) 14 | 1. [Constructor](#constructor) 15 | 1. [Attributes](#attributes) 16 | 1. [Methods](#methods) 17 | 1. [To do](#to-do) 18 | 19 | ## Features 20 | 21 | * Integrated markdown editing and HTML viewing modes - end editing and HTML is shown, click somewhere on the HTML text and markdown editing starts in the same position. 22 | * Markdown editing supported by additional keys. (Thanks JonB for help on Obj-C.) 23 | * Implements ui.TextView API for easy integration to existing code. 24 | * Customizable through attributes or CSS. 25 | 26 | ##Quick start 27 | 28 | Download (from [Github](https://github.com/mikaelho/pythonista-markdownview)) both the `MarkdownView.py` script and this `readme.md` file into same directory, and you can run the script in Pythonista to view and try out editing this readme file. 29 | 30 | Import MarkdownView as a module and use wherever you would use a TextView. Markdown text can be set and accessed with the _text_ attribute. Implement a delegate with `textview_did_end_editing` or `textview_did_change` method - see the end of the MarkdownView.py file for an example. 31 | 32 | ## Link behavior 33 | 34 | When you click a link in the HTML view: 35 | 36 | * `http:` and `https:` links are opened in Safari 37 | * Document-internal links (`#something`) are followed within MarkdownView 38 | * `file:` links are opened in Pythonista built-in browser 39 | * All other links like `twitter:` are opened as defined by the OS, i.e. in the relevant app, if installed. 40 | 41 | You can change the handling of internal and external links by implementing a proxy with either or both of the following methods. Both should return True if MarkdownView should load the link. 42 | 43 | * `webview_should_load_internal_link(webview, url)` 44 | * `webview_should_load_external_link(webview, url)` 45 | 46 | ## Additional keys 47 | 48 | Extra keys help with markdown editing, but are in no way required, and can be turned off with `additional_keys = False` argument at instantiation. Please refer to markdown [syntax](https://daringfireball.net/projects/markdown/syntax) if not already familiar. 49 | 50 | Keys: 51 | 52 | * __⇥__ - Indent - Repeat adds more spaces, 2 at a time. 53 | * __⇤__ - Outdent - Removes 2 spaces at time. 54 | * __>__ - Quote - Repeat adds levels; there is no special support for removing levels. 55 | * __[]__ - Links and images - If a range is selected, the contents of the range is used for both visible text and the link; for images, you have to add the exclamation mark at the front. 56 | * __#__ - Headings - Adds a level with each click; fourth click removes all hashes. 57 | * __`1.`__ - Numbered list - Replaces unordered list markers if present. Repeat removes. Indenting increases the list level. 58 | * __•__ - Bullet list - Regular unordered list, otherwise like the numbered list. 59 | * __`_`__ - Emphasis - If a range is selected, inserts an underscore at both ends of the selection. Once for emphasis, twice for strong, three times for both. Fourth time removes the underscores. 60 | * __`__ - Backtick - Insert backtick or backticks around selection to indicate code. Removes backticks if already there. 61 | 62 | For all of the above, where it makes sense, if several lines are selected, applies the change to all the lines regardless of whether they have been selected only partially. 63 | 64 | ## Component motivation and design principles 65 | 66 | * Provide some rich-text editing capabilities for Pythonista UI scripts 67 | * In a format that is not locked to specific propietary format & program 68 | * Easy to deploy to an existing solution 69 | * Is robust (unlike straight HTML that tends to get confusing with styles etc.) 70 | * Make markdown editing as easy as possible, with the transition between editing and viewing as seamless as possible (no 'Edit' button) 71 | * Do not require thinking about or taking screen space for UI elements like toolbars (i.e. be conscious of and support small screens like iPhone) 72 | * Is lightweight, understandable and manageable by a Python programmer (which would not be the case when using e.g. TinyMCE in a WebView) 73 | * Not a web browser 74 | 75 | ## Constructor 76 | 77 | `MarkdownView(frame = None, flex = None, background_color = None, name = None, accessory_keys = True, extras = [], css = None)` 78 | 79 | Parameters: 80 | 81 | * `frame`, `flex`, `background_color`, `name` - As standard for a view. 82 | * `accessory_keys` - Whether to enable the additional keys for markdown editing or not; see the section on additional keys, above. 83 | * `extras` - Any [markdown2 extras](https://github.com/trentm/python-markdown2/wiki/Extras) you want active, as an array of strings. As an example, this document relies on `"header-ids"` for the table of contents links. 84 | * `css` - Provide your own CSS styles as a string; see the attribute description, below. 85 | 86 | ## Attributes 87 | 88 | * `alignment` - As TextView, affects WebView as well 89 | * `autocapitalization_type` - As TextView 90 | * `autocorrection_type` - As TextView 91 | * `auto_content_inset` - As TextView 92 | * `background_color` - As TextView, affects WebView as well 93 | * `css` - Provide your own CSS styles, or set to an empty string to get the WebView defaults. Note that if you provide this, you have to provide all styles, not just your additions. Include `$font_size`, `$font_family`, `$text_color` and `$text_align` keywords in the appropriate places if you still want to have e.g. the `font` attribute of `MarkdownView` affect also the `WebView`. 94 | * `delegate` - Set an object that handles `TextView` and `WebView` delegated method calls. Following methods are supported: 95 | * `textview_should_begin_editing`, `textview_did_begin_editing`, `textview_did_end_editing`, `textview_should_change`, `textview_did_change`, `textview_did_change_selection` 96 | * `webview_should_start_load` (deprecated by the custom methods below), `webview_did_start_load`, `webview_did_finish_load`, `webview_did_fail_load` 97 | * And the two custom methods; see the section on link behavior for details: `webview_should_load_internal_link`, `webview_should_load_external_link` 98 | * `editable` - True by default. Setting to False means you get the HTML view only. Could be useful if your app has different users and modes for "editor" and "viewer". 99 | * `editing` - True if currently in markdown editing mode 100 | * `font` - As TextView, affects WebView as well 101 | * `keyboard_type` - As TextView 102 | * `margins` - Tuple (), default is no margins 103 | * `scales_page_to_fit` - as WebView 104 | * `selectable` - As TextView, does not affect WebView 105 | * `selected_range` - As TextView, does not work on WebView mode 106 | * `spellchecking_type` - As TextView 107 | * `text` - The markdown text 108 | * `text_color` - As TextView, affects WebView as well 109 | 110 | ## Methods 111 | 112 | * `preferred_size()` - returns a size (width, height) tuple based on what size the component would want to be, based on its contents. This is based on the TextView or the WebView contents, depending on whether we are currently editing or not. If you want to explicitly use one or the other, pass a `using=markdown` or `using=html` parameter. You can control min/max dimensions returned by providing some of the following parameters: `min_width`, `max_width`, `min_height`, `max_height`. (The processing of these parameters is optimized for horizontal text.) 113 | * `replace_range()` - as TextView 114 | * `size_to_fit()` - sizes the component based on the TextView or the WebView contents, depending on whether we are currently editing or not. See `preferred_size()` for optional parameters. 115 | * `to_html()` - returns the HTML of the WebView. This contains all MarkdownView-specific styles and JavaScript code. If you need just the markdown content converted to HTML, include `content_only=True` as a parameter. 116 | 117 | ## To do 118 | 119 | * Increase reliability of the HTML-to-markdown transition 120 | * Swipe right to go back after following an in-document link --------------------------------------------------------------------------------