@@ -86,29 +86,52 @@ def _export_header(section, status_source, variant="h3"):
8686
8787class EditableProse (JSComponent ):
8888 """
89- A paragraph the user can edit in place, like a document editor.
89+ A paragraph of Markdown the user can edit in place, like a document editor.
9090
91- Typing syncs the text back to ``value``. ``value`` is only written back into
92- the element when it differs from what is already there, because assigning to
93- a contenteditable node resets the caret to the start, which would make it
91+ It shows the rendered Markdown until it is clicked, then swaps to the raw
92+ text to edit and renders again on blur, so the story reads as prose rather
93+ than as Markdown source. ``value`` is only written back into the element
94+ when it differs from what is already there, because assigning to a
95+ contenteditable node resets the caret to the start, which would make it
9496 impossible to type.
9597 """
9698
9799 value = param .String (default = "" , doc = """
98- The edited text.""" )
100+ The Markdown source of the paragraph.""" )
101+
102+ _rendered = param .String (default = "" , doc = """
103+ ``value`` rendered to HTML, shown while the paragraph is not edited.""" )
99104
100105 _esm = """
101106 export function render({ model }) {
102107 const div = document.createElement('div')
103- div.contentEditable = 'true'
104108 div.className = 'editable-prose'
105- div.textContent = model.value
109+ div.title = 'Click to edit'
110+
111+ const show = () => { div.innerHTML = model._rendered }
112+ const edit = () => {
113+ div.textContent = model.value
114+ div.contentEditable = 'true'
115+ div.classList.add('editing')
116+ div.focus()
117+ }
118+
119+ div.addEventListener('click', () => { if (div.contentEditable !== 'true') edit() })
106120 div.addEventListener('input', () => { model.value = div.textContent })
121+ div.addEventListener('blur', () => {
122+ div.contentEditable = 'false'
123+ div.classList.remove('editing')
124+ show()
125+ })
126+ model.on('_rendered', () => { if (div.contentEditable !== 'true') show() })
107127 model.on('value', () => {
108- if (div.textContent !== model.value) {
128+ // Only while editing, and only if it really changed, or the caret jumps.
129+ if (div.contentEditable === 'true' && div.textContent !== model.value) {
109130 div.textContent = model.value
110131 }
111132 })
133+
134+ show()
112135 return div
113136 }
114137 """
@@ -119,13 +142,30 @@ class EditableProse(JSComponent):
119142 outline: none;
120143 padding: 4px 6px;
121144 border-radius: 4px;
122- white-space: pre-wrap ;
145+ cursor: text ;
123146 }
147+ .editable-prose > :first-child { margin-top: 0; }
148+ .editable-prose > :last-child { margin-bottom: 0; }
124149 .editable-prose:hover { background: rgba(127, 127, 127, 0.08); }
125- .editable-prose:focus { background: rgba(127, 127, 127, 0.12); }
150+ .editable-prose.editing {
151+ background: rgba(127, 127, 127, 0.12);
152+ white-space: pre-wrap;
153+ }
126154 """
127155 ]
128156
157+ def __init__ (self , ** params ):
158+ super ().__init__ (** params )
159+ self ._render_markdown ()
160+
161+ @param .depends ("value" , watch = True )
162+ def _render_markdown (self ):
163+ # Rendered with the same engine as Panel's Markdown pane, so the story
164+ # reads identically on screen and in the exports.
165+ from markdown_it import MarkdownIt
166+
167+ self ._rendered = MarkdownIt ("gfm-like" ).render (self .value )
168+
129169
130170class Task (Viewer ):
131171 """
0 commit comments