<body>
  <form method="post" action="dump.php">
    <div id="tiny-ui">
      <textarea id="comments-embedded" style="width: 100%; height: 500px;">

<h1>Proposal Title</h1>
<p>Dear {Proposal_Fund_Contact},</p>
<p>After exploring your call for proposals, My name is {Contact_Name} and as a representative for {company_name}, the following outlines how our digital marketing service and <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_34420829621663914009497" data-mce-annotation="tinycomments">decades of experience can significantly</span> increase your site traffic, drawing new audiences to the museum.</p>
<p>With our experience, we can help you meet and exceed the 20% increase proposed as an initial target.</p>
<h2>Summary</h2>
<p>Our services are summarized under three key service areas:</p>
<ul>
<li>Blog Content</li>
<li>Video Production</li>
<li>Lead Magnets</li>
</ul>
<h2>Blog Content</h2>
<p>Our expertise takes content writers from the audience analysis, research and writing stage, through review and editing, and finally to publication. Previous audits into past work for clients have shown significant increases in traffic, for example, <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_13629681821663664152152" data-mce-annotation="tinycomments">a result of 11,200 views per quarter to 14,883 views per quarter for one previous client</span> (Testimonies and Results, Appendix A).</p>
<h3>Video Production</h3>
<p>With sharp design and editing our video production team have produced trailers, sizzle reels, and demos across a variety of industries, increasing followers across video social media sources up to 55% in some instances.</p>
<h3>Lead Magnets</h3>
<p>A good lead magnet provides an essential entry point for attracting qualified leads. We can set up lead magnets, and produce valuable content to target your audience, garnering their respect and collecting contact information for follow up.</p>
<h3>Background&nbsp;</h3>
<ul>
<li>
<p>WIP</p>
</li>
</ul>
<h3>Objectives</h3>
<ul>
<li>
<p><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_32185374131663914463261" data-mce-annotation="tinycomments">WIP</span></p>
</li>
</ul>
<h3>Methods</h3>
<ul>
<li>
<p>WIP</p>
</li>
</ul>
<h3><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_36510162521663724338871" data-mce-annotation="tinycomments">Budgets and Schedules</span></h3>
<p>&nbsp;</p>
<p>&nbsp;</p>
<div align="left">
<table style="border-collapse: collapse; width: 99.9976%;" border="1"><colgroup><col style="width: 25.9099%;"><col style="width: 61.6685%;"><col style="width: 12.421%;"></colgroup>
<tbody>
<tr>
<td>
<p>Blog Creation</p>
</td>
<td>
<p>Research will inform blog topics and content relevant to your industry and target audience.</p>
</td>
<td>
<p><span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_35814127541663915116369" data-mce-annotation="tinycomments">$450</span></p>
</td>
</tr>
<tr>
<td>
<p>Lead Magnets</p>
</td>
<td>
<p>Valuable long-form content relevant to your industry and targeted audience, including individual landing pages and lead forms.</p>
</td>
<td>
<p>$1900</p>
</td>
</tr>
</tbody>
</table>
</div>
<p>&nbsp;</p>
<h3>Appendices</h3>
<p>- <span class="mce-annotation tox-comment" data-mce-annotation-uid="mce-conversation_10601721751663915458087" data-mce-annotation="tinycomments">Testimonies and Results</span></p>
<!--tinycomments|2.1|data:application/json;base64,eyJtY2UtY29udmVyc2F0aW9uXzM0NDIwODI5NjIxNjYzOTE0MDA5NDk3Ijp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzQ0MjA4Mjk2MjE2NjM5MTQwMDk0OTciLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zNDQyMDgyOTYyMTY2MzkxNDAwOTQ5NyIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJJIHRoaW5rIHRoaXMgc2hvdWxkIGJlIGEgbmV3IHNlbnRlbmNlIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0xOFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMThUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMjAxODIxNDI0OTMyMTQ4NjQxNTM3ODMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC8xWFRxemI1ZEVpSmRiRzQ3TmtwV0NyL2VmNjkxNTk3Mjk1MTMxMDUwOWVjMDAwODEwZDY3MTIzL2ltZy1tYXJ0aGEtd29vbGYtY29tbWVudHMtZGVtby5wbmc/aD01MDAiLCJjb250ZW50IjoiU29tZXRoaW5nIGxpa2Ug4oCYLiBPdXIgRGVjYWRlcyBvZiBleHBlcmllbmNlIGNhbuKApuKAmSBUaG91Z2h0cz8iLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTE5VDAxOjQwOjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0xOVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8zMjEyMTQyNDk0ODYyMDE4NDE1Mzc4MyIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSeKAmWxsIHdyaXRlIHVwIHNvbWUgc2FtcGxlIHNlbnRlbmNlcyIsImNyZWF0ZWRBdCI6IjIwMjItMDktMTlUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTE5VDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzEzNjI5NjgxODIxNjYzNjY0MTUyMTUyIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMTM2Mjk2ODE4MjE2NjM2NjQxNTIxNTIiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xMzYyOTY4MTgyMTY2MzY2NDE1MjE1MiIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJJcyB0aGlzIG1ldGhvZG9sb2d5IHdlbGwgdGhvdWdodCBvdXQgYW5kIGNsZWFyPyBJIHRoaW5rIHRoZXJl4oCZcyBzb21lIGJldHRlciByZXN1bHRzIHdlIGNhbiB1c2UiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTE5VDAxOjM4OjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0xOVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8xNDgzNzIwMTgyMTI4MzQ2NDE1MjQ5MyIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJBIGdvb2Qgc3VnZ2VzdGlvbiwgY2FuIHdlIHBlcmhhcHMgcHV0IGluIHRocmVlIGV4YW1wbGVzIHRvIGJldHRlciBiYWNrIHVwIHRoZSBhcmd1bWVudCBoZXJlLiIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIwVDAxOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzMyMTg1Mzc0MTMxNjYzOTE0NDYzMjYxIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzIxODUzNzQxMzE2NjM5MTQ0NjMyNjEiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zMjE4NTM3NDEzMTY2MzkxNDQ2MzI2MSIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSSBoYXZlIHNvbWUgaWRlYXMgZm9yIHRoaXMsIEnigJlsbCBzZW5kIHRoZW0gdGhyb3VnaCBzb29uIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMjE0ODY0MjAxODIxNDI0OTMxNTM3ODMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiRG9u4oCZdCBmb3JnZXQgdGhlIG5ldyBpbml0aWF0aXZlcyBwbGFubmVkIGZvciBuZXh0IHllYXIg4oCTIHNlZSB0aGUgR29vZ2xlIERyaXZlIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMVQwMTo0MDo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjFUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfOTQ4NjIwMTg0MzIxMjE0MjQxNTM3ODMiLCJhdXRob3IiOiJILkxlaG1lbkBlbWFpbC5jb20iLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC83c2o3dEo2bjZFOW5aazBNRlRwbmpJLzEyNDE1MTFkZTFmYjI0MzEwOGZjOTY4MzFkYTk5ZTY4L2ltZy1ob25leS1sZWhtZW4tY29tbWVudHMtZGVtby5qcGc/aD01MDAiLCJhdXRob3JOYW1lIjoiSG9uZXkgTGVobWVuIiwiY29udGVudCI6IkdvdCBpdCwgdGhhbmtzIE1hcnRoYSA6KSIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjFUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIxVDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzM1ODE0MTI3NTQxNjYzOTE1MTE2MzY5Ijp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzU4MTQxMjc1NDE2NjM5MTUxMTYzNjkiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8zNTgxNDEyNzU0MTY2MzkxNTExNjM2OSIsImF1dGhvciI6IkguTGVobWVuQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4Lzdzajd0SjZuNkU5blprME1GVHBuakkvMTI0MTUxMWRlMWZiMjQzMTA4ZmM5NjgzMWRhOTllNjgvaW1nLWhvbmV5LWxlaG1lbi1jb21tZW50cy1kZW1vLmpwZz9oPTUwMCIsImF1dGhvck5hbWUiOiJIb25leSBMZWhtZW4iLCJjb250ZW50IjoiSSBqdXN0IGNoZWNrZWQgaW4gd2l0aCBmaW5hbmNlLCBhbmQgd2UgbWF5IG5lZWQgdG8gdXBkYXRlIHRoaXMiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTIwVDAxOjM4OjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF8yMTE1NDIwMTgyMTQ4NjQ5MzQyMzc4MyIsImF1dGhvciI6Ik0uV29vbGZAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvMVhUcXpiNWRFaUpkYkc0N05rcFdDci9lZjY5MTU5NzI5NTEzMTA1MDllYzAwMDgxMGQ2NzEyMy9pbWctbWFydGhhLXdvb2xmLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ik1hcnRoYSBXb29sZiIsImNvbnRlbnQiOiJKdXN0IGNvbmZpcm1pbmcgdGhpcyBpcyB0aGUgY2FzZT8gSeKAmW0gYSBiaXQgY29uZnVzZWTigKYiLCJjcmVhdGVkQXQiOiIyMDIyLTA5LTIxVDAxOjQwOjU4Ljg4NFoiLCJtb2RpZmllZEF0IjoiMjAyMi0wOS0yMVQwMTozODo1OC44ODRaIn0seyJ1aWQiOiJtY2UtY29tbWVudF85NDg2MjAxODQzMjEyMTQyNDE1Mzc4MyIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJJIHRoaW5rIGl04oCZcyB0aGUgc2FtZSByYXRlPyIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjJUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIyVDAyOjM4OjU4Ljg4NFoifSx7InVpZCI6Im1jZS1jb21tZW50XzEyMzIxMjE0MjQxNTM3ODYyMDE4NDgzIiwiYXV0aG9yIjoiSC5MZWhtZW5AZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvN3NqN3RKNm42RTluWmswTUZUcG5qSS8xMjQxNTExZGUxZmIyNDMxMDhmYzk2ODMxZGE5OWU2OC9pbWctaG9uZXktbGVobWVuLWNvbW1lbnRzLWRlbW8uanBnP2g9NTAwIiwiYXV0aG9yTmFtZSI6IkhvbmV5IExlaG1lbiIsImNvbnRlbnQiOiJBaCEgWW914oCZcmUgcmlnaHQsIHNvcnJ5IHdyb25nIGNvbnRyYWN0LiBGZWVsIGZyZWUgdG8gZGVsZXRlLiIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjNUMDI6NDA6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIzVDAyOjM4OjU4Ljg4NFoifV19LCJtY2UtY29udmVyc2F0aW9uXzM2NTEwMTYyNTIxNjYzNzI0MzM4ODcxIjp7InVpZCI6Im1jZS1jb252ZXJzYXRpb25fMzY1MTAxNjI1MjE2NjM3MjQzMzg4NzEiLCJjb21tZW50cyI6W3sidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xOTY3OTYwMDIyMTYyMTM5OTcwMzkxNSIsImF1dGhvciI6IkcuQWxiZWVAZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvM092V0pLSkNvQ2d4NDMwcm5BY1dJaS83ZTdhYWIyNmJlNGMzOWY0ZjhkOTBmOWI0ZTljZDFlZS9pbWctZ2VvcmdlLWFsYmVlLWNvbW1lbnRzLWRlbW8ucG5nP2g9NTAwIiwiYXV0aG9yTmFtZSI6Ikdlb3JnZSBBbGJlZSIsImNvbnRlbnQiOiJJcyB0aGUgcHJvcG9zYWwgYnVkZ2V0IHJlYXNvbmFibGU/IiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMFQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjBUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMTAyOTU2MTkzODQ1ODM0OTM4NDYyOTMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiWWVzIEnigJl2ZSBoYWQgaXQgY2hlY2tlZCB0aHJvdWdoIGJ5IHRoZSBhdWRpdG9yIHN1Z2dlc3RlZC4gSXMgdGhlcmUgYW55IG1vcmUgdG8gYWRkIGhlcmUgb3IgY2FuIHdlIHJlc29sdmUgdGhlIGRpc2N1c3Npb24/IiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMVQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjFUMDE6Mzg6NTguODg0WiJ9XX0sIm1jZS1jb252ZXJzYXRpb25fMTA2MDE3MjE3NTE2NjM5MTU0NTgwODciOnsidWlkIjoibWNlLWNvbnZlcnNhdGlvbl8xMDYwMTcyMTc1MTY2MzkxNTQ1ODA4NyIsImNvbW1lbnRzIjpbeyJ1aWQiOiJtY2UtY29udmVyc2F0aW9uXzEwNjAxNzIxNzUxNjYzOTE1NDU4MDg3IiwiYXV0aG9yIjoiRy5BbGJlZUBlbWFpbC5jb20iLCJhdXRob3JBdmF0YXIiOiJodHRwczovL2ltYWdlcy5jdGZhc3NldHMubmV0L3M2MDBqajQxZ3NleC8zT3ZXSktKQ29DZ3g0MzBybkFjV0lpLzdlN2FhYjI2YmU0YzM5ZjRmOGQ5MGY5YjRlOWNkMWVlL2ltZy1nZW9yZ2UtYWxiZWUtY29tbWVudHMtZGVtby5wbmc/aD01MDAiLCJhdXRob3JOYW1lIjoiR2VvcmdlIEFsYmVlIiwiY29udGVudCI6IkkganVzdCBnb3QgYSBuZXcgdGVzdGltb25pYWwgZnJvbSBvdXIgbGFzdCBjdXN0b21lci4gSeKAmWxsIGZvcndhcmQgdG8gdGhlIFNsYWNrIGNoYXQgZm9yIGFwcHJvdmFsIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMlQwMTozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjJUMDE6Mzg6NTguODg0WiJ9LHsidWlkIjoibWNlLWNvbW1lbnRfMTkzOTMwMjk1NjE4NDYyOTg0NTgzNDMiLCJhdXRob3IiOiJNLldvb2xmQGVtYWlsLmNvbSIsImF1dGhvckF2YXRhciI6Imh0dHBzOi8vaW1hZ2VzLmN0ZmFzc2V0cy5uZXQvczYwMGpqNDFnc2V4LzFYVHF6YjVkRWlKZGJHNDdOa3BXQ3IvZWY2OTE1OTcyOTUxMzEwNTA5ZWMwMDA4MTBkNjcxMjMvaW1nLW1hcnRoYS13b29sZi1jb21tZW50cy1kZW1vLnBuZz9oPTUwMCIsImF1dGhvck5hbWUiOiJNYXJ0aGEgV29vbGYiLCJjb250ZW50IjoiTmljZSB3b3JrISIsImNyZWF0ZWRBdCI6IjIwMjItMDktMjJUMDI6Mzg6NTguODg0WiIsIm1vZGlmaWVkQXQiOiIyMDIyLTA5LTIyVDAyOjM4OjU4Ljg4NFoifSx7InVpZCI6Im1jZS1jb21tZW50XzQ2MTkyODQ4MTkzOTM4NzQ2NjE4MzQzIiwiYXV0aG9yIjoiSC5MZWhtZW5AZW1haWwuY29tIiwiYXV0aG9yQXZhdGFyIjoiaHR0cHM6Ly9pbWFnZXMuY3RmYXNzZXRzLm5ldC9zNjAwamo0MWdzZXgvN3NqN3RKNm42RTluWmswTUZUcG5qSS8xMjQxNTExZGUxZmIyNDMxMDhmYzk2ODMxZGE5OWU2OC9pbWctaG9uZXktbGVobWVuLWNvbW1lbnRzLWRlbW8uanBnP2g9NTAwIiwiYXV0aG9yTmFtZSI6IkhvbmV5IExlaG1lbiIsImNvbnRlbnQiOiIqYXBwbGF1c2UgYXBwbGF1c2UqIiwiY3JlYXRlZEF0IjoiMjAyMi0wOS0yMlQwMzozODo1OC44ODRaIiwibW9kaWZpZWRBdCI6IjIwMjItMDktMjJUMDM6Mzg6NTguODg0WiJ9XX19-->
</textarea>
    </div>
  </form>
</body>

</html>
// Demo backend data
const currentAuthor = {
  author: 'M.Woolf@email.com',
  authorName: 'Martha Woolf'
};
const authorAvatars = {
  'Martha Woolf': 'https://images.ctfassets.net/s600jj41gsex/1XTqzb5dEiJdbG47NkpWCr/ef6915972951310509ec000810d67123/img-martha-woolf-comments-demo.png?h=500',
  'George Albee': 'https://images.ctfassets.net/s600jj41gsex/3OvWJKJCoCgx430rnAcWIi/7e7aab26be4c39f4f8d90f9b4e9cd1ee/img-george-albee-comments-demo.png?h=500',
  'Honey Lehmen': 'https://images.ctfassets.net/s600jj41gsex/7sj7tJ6n6E9nZk0MFTpnjI/1241511de1fb243108fc96831da99e68/img-honey-lehmen-comments-demo.jpg?h=500'
};
const admin = 'M.Woolf@email.com';
const docName = "Proposal Template";

// Time since last save
let documentSaveTimestamp = new Date();

// Frontend functionality

// This is the eventLog API, as it is available through the `editor` API object
const getEventLog = (editor) => {
  return editor.plugins.tinycomments.getEventLog({
    after: documentSaveTimestamp.toISOString()
  });
};

const timestampToLocalString = (timestamp) => new Date(timestamp).toDateString();

// Retrieve conversation data from the eventLog
const getRecipientData = (eventLog) => {
  const recipientData = {}; // Record of recipients by id (author)
  const conversationData = {}; // Record of conversations by Id
  
  // Removes conversation from log in entirety, and handles when recipients no longer need updates
  const removeConversationFromLog = (convId) => {
  delete conversationData[convId];
  Object.keys(recipientData).forEach((id) => {
  // Iterate through each key in recipientData
    if (recipientData[id].conversations.has(convId)) {
    // Remove conversation from recipient
      recipientData[id].conversations.delete(convId);
    if (recipientData[id].conversations.size === 0) {
    // Remove recipient if they no longer have any conversation updates
      delete recipientData[id];
    }
    }
  });
  };
  
  // Adds conversation information from event to conversationData, and updates recipientLog
  const conversationEvent = (event) => {
  const convId = event.conversationUid;
  const eventTime = timestampToLocalString(event.timestamp);
  const eventType = event.type;
  
  // Adds convAuthor as recipient to changes in this conversation
  const addConversationToRecipient = (convAuthor) => {
  // convAuthor is an object with `author` and `authorName`,
  // consistent with the eventLog and `currentAuthor` above
    if (Object.keys(recipientData).includes(convAuthor.author)) {
    // The `author` value is used as an id for the `recipientData` object
      recipientData[convAuthor.author].conversations.add(convId);
    } else {
    recipientData[convAuthor.author] = {
      name: convAuthor.authorName,
      // Set of conversation Ids related to the recipient, for inclusion after eventLog is parsed
      conversations: new Set([convId]),
      // Placeholder for inserting the correct conversationData after eventLog is parsed
      conversationData: {}
    };
    }
  };

  const createConversation = (convStatus, convAuthor) => {
    conversationData[convId] = {
      time: eventTime,
    context: event.conversationContext,
    status: convStatus,
    commentLog: {}
    };
    conversationData[convId].commentLog[convId] = {
    status: convStatus,
    comment: event.conversationContent,
    authorName: convAuthor.authorName,
      time: eventTime,
    };
    addConversationToRecipient(convAuthor);
  };

  const existingConversation = (convAuthor) => {
    const conversationTime = timestampToLocalString(event.conversationCreatedAt);
    conversationData[convId] = {
      time: conversationTime,
    context: event.conversationContext,
    status: "exist",
    commentLog: {}
    };
    conversationData[convId].commentLog[convId] = {
    status: "exist",
    comment: event.conversationContent,
    authorName: convAuthor.authorName,
    time: conversationTime
    };
    addConversationToRecipient(convAuthor);
  };
  
  // Type: 'delete', 'resolve'
  const deleteResolveConversation = () => {
    if (conversationData[convId].status === "create") {
    // Remove new conversation in entirety
    removeConversationFromLog(convId);
    } else {
    // Modify conversation time and status
    conversationData[convId].status = eventType;
    conversationData[convId].time = eventTime;
    }
  };
  
  // Type: 'reply', 'edit-comment', 'delete-comment'
  const commentEvent = () => {
    if (!Object.keys(conversationData[convId].commentLog).includes(event.commentUid)) {
    // Add new comment to commentLog
    conversationData[convId].commentLog[event.commentUid] = {
      status: eventType,
      comment: event.commentContent,
      authorName: currentAuthor.authorName,
      time: eventTime
    };
    } else if (eventType === "delete-comment" && conversationData[convId].commentLog[event.commentUid].status === "reply") {
    // Comment created in this session is deleted
      delete conversationData[convId].commentLog[event.commentUid];
    if (conversationData[convId].status === "exist" && Object.keys(conversationData[convId].commentLog).length === 1) {
    // Existing conversation returns to state at page open
      removeConversationFromLog(convId);
    }
    } else {
    // Comment updated in same session
    conversationData[convId].commentLog[event.commentUid].comment = event.commentContent;
    conversationData[convId].commentLog[event.commentUid].time = eventTime;
    }
  };
  
  // Event is handled after this point
  if (eventType === "create") {
    // Conversation creation event
    createConversation(eventType, currentAuthor);
  } else {
    // Existing conversation,
    if (!Object.keys(conversationData).includes(convId)) {
      // add a new empty conversation record
    existingConversation(event.conversationAuthor);
    }
    
    // Conversation in Log after this point, and not create event
    if (eventType === "delete" || eventType === "resolve") {
    deleteResolveConversation();
    } else {
    commentEvent();
    }
  }
  };
  
  
  // Called for each event in eventLog
  const handleEvent = (event) => {
  const eventTime = timestampToLocalString(event.timestamp);
    if (event.type === "delete-all-conversations") {
   /* If this event is triggered, only those which are
    already recorded in conversation log will be visible.
    This means that there will be no information about
    the deletion of existing conversations unless they
    are first replied to or modified in any other way.
   */
    Object.keys(conversationData).forEach((convId) => {
    if (conversationData[convId].status === "create") {
    // new conversations are removed from log
      removeConversationFromLog(convId);
    } else {
    // Existing conversations remain in the log
      conversationData[convId].status = "delete";
      conversationData[convId].time = eventTime;
    }
    });
  } else {
  // Conversation event, event has conversation information
    conversationEvent(event);
  }
  };

  // Handle each event in the eventLog
  eventLog.events.forEach(handleEvent);

  // Use recipient id (email) to store email content
  Object.keys(recipientData).forEach((id) => {
    const recipient = recipientData[id];
  Array.from(recipient.conversations).forEach((convId) => {
    recipientData[id].conversationData[convId] = conversationData[convId]
  });
  delete recipientData[id].conversations;
  });

  return recipientData;
};


// Builds email content from recipients and conversationLog objects
const getEmails = (recipientData) => {
  const emailCSS = `<style>
    p, div {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
  }
  div.email-template {
    background: #E8F1F8;
    border-radius: 20px;
    font-style: normal;
    padding: 17px 43px 40px 35px;
    display: flex;
    flex-direction: column;
    flex-grow: 1;
    align-content: space-between;
    gap: 20px;
  }
  div.email-conversation-header, div.email-comment {
    padding-bottom: 16px;
  }
  div.email-conversation {
    font-size: 16px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    padding: 16px;
    background: #FFFFFF;
    border-radius: 10px;
  }
  div.email-comment {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    border-bottom: 1px solid #E8F1F8;
  }
  div.email-conversation-header {
    border-bottom: 1px solid #E8F1F8;
    display: flex;
    flex-direction: row;
    justify-content: space-between;
    align-items: center;
    gap: 16px;

    flex: none;
    order: 0;
  }
  div.email-conversation-header, div.email-comment, div.email-comment-data, div.email-comment-author {
    align-self: stretch;
    flex-grow: 0;
  }
  div.email-conversation-title {
    font-weight: 700;
  }
  div.email-conversation-time, div.email-comment-time {
    font-weight: 400;
    color: #646D78;
    font-size: 13px;
  }
  div.email-comment-data {
  }
  div.email-comment-name {
    font-weight: 700;
  }
  p.email-conversation-link {
    font-weight: 400;
    font-size: 13px;
    line-height: 18px;
    color: #4099FF;
    margin-bottom: 0px;
    }
  div.email-comment-author img {
    margin-right: 7px;
    float: left;
    border-radius: 50%;
  }
  </style>`;
  
  // Reference object for replacing conversation status text
  const conversationStatus = {
    'create': 'Created ',
  'exist': 'Created ',
  'delete': 'Deleted ',
  'resolve': 'Resolved '
  };
  
  // Reference object for replacing comment status text
  const commentStatus = {
    'create': 'Started conversation',
    'exist': 'Started conversation',
    'reply': 'Replied',
  'delete-comment': 'Deleted',
  'edit-comment': 'Edited'
  };

  // Takes commentLog and returns function which takes commentId which:
  // Returns a comment from the commentLog as html using the commentId
  const getCommentHtml = (commentLog) => (commentId) => {
    const commentData = commentLog[commentId];
  return `<div class="email-comment" id="${commentId}">
    <div class="email-comment-data"><p>${commentData.comment}</p></div>
    <div class="email-comment-author">
      <img src="${authorAvatars[commentData.authorName]}" width=32px height=32px/>
    <div class="email-comment-name">${commentData.authorName}</div>
    <div class="email-comment-time">${commentStatus[commentData.status]} ${commentData.time}</div>
    </div>
  </div>`;
  };
  
  // Returns a conversation from the conversationLog as html using the convId
  const getConversationHtml = (conversationData) => (convId) => {
    const conversation = conversationData[convId];
  const commentLog = conversation.commentLog;
  const commentHtml = Object.keys(commentLog).map(getCommentHtml(commentLog)).join(`\n`);
  return `<div class="email-conversation" id="${convId}">
    <div class="email-conversation-header">
      <div class="email-conversation-title">"${conversation.context}" conversation</div>
    <div class="email-conversation-time">${conversationStatus[conversation.status]}${conversation.time}</div>
    </div>
    ${commentHtml}
    <p class="email-conversation-link">Open conversation</p>
    </div>`;
  };
  
  // Store email content in an object
  const recipientEmails = {};
  
  // Use recipient id (email) to store email content
  Object.keys(recipientData).forEach((id) => {
    const recipient = recipientData[id];
  const conversationData = recipient.conversationData;
  const conversationHtml = Object.keys(conversationData).map(getConversationHtml(conversationData)).join(`\n`);
    recipientEmails[id] = emailCSS + `<div class="email-template">
    <div class="email-header">Hi ${recipient.name}, here is an update about the document <strong>"${docName}"</strong></div>
    ${conversationHtml}
    </div>`;
  });
  return recipientEmails;
};

// Builds a dialog for displaying email content for each recipient, for demonstration purposes
const getEmailDialogConfig = (recipientData) => {
  // Retrieves email content for each recipient
  const recipientEmails = getEmails(recipientData);

  // Builds array for displaying recipients in dialog selectbox
  const recipientItems = Object.keys(recipientData).map((id) => {
    const recipient = recipientData[id];
  return {
    value: id,
    text: recipient.name
  };
  });
  
  // Returned schema for the dialog opened by the "Email" button, containing:
  // Selectbox of recipients; Iframe of email html content, submit button for sending the email.
  const emailDialogConfig = {
  title: 'Email Preview',
  size : 'large',
  body: {
    type: 'panel',
    title: 'Comments Update - Email Notification',
    items: [
    {
      type: 'selectbox',
      name: 'recipient',
      label: 'Email Recipient',
      maximized: true,
      items: recipientItems
    },
    {
      type: 'iframe',
      name: 'emailTemplate',
      label: 'Email Template'
    }
    ]
  },
  buttons: [
    {
    type: 'cancel',
    name: 'closeButton',
    text: 'Close'
    },
    {
    type: 'submit',
    name: 'outputButton',
    text: 'Output to Log',
    buttonType: 'primary'
    }
  ],
  initialData: {
    emailTemplate: recipientEmails[recipientItems[0].value]
  },
  onChange: (dialogApi, details) => {
  // onChange called when new recipient is selected
    const data = dialogApi.getData();
    dialogApi.setData({
      emailTemplate: recipientEmails[data.recipient]
    });
  },
  onSubmit: (dialogApi) => {
  // onSubmit called when 'Save and Send Email' button is pressed
    // Two methods for retrieving the email html within this dialog:
    const data = dialogApi.getData();
    
    // Second retrieves the email html using the `getEmailHtml` function;
    // this method doesn't require the dialog to contain an iframe.
    console.log(recipientEmails[data.recipient]);
    dialogApi.close();
  }
  };
  
  return emailDialogConfig;
};

// This dialog allows the user to choose to open a dialog to preview emails
const getPreviewDialogConfig = (showEmail) => {
  const previewDialogConfig = {
  title: 'Show Preview',
  size : 'medium',
  body: {
    type: 'panel',
    title: 'Show Preview Question',
    items: [
    {
      type: 'htmlpanel',
      html: '<div>Would you like to preview the emails which have been sent out to other participants in this document?</div>'
    },
    {
      type: 'alertbanner',
      level: 'info',
      icon: 'help',
      text: 'This preview is for demonstration purposes only.'
    }
    ]
  },
  buttons: [
    {
    type: 'cancel',
    name: 'noButton',
    text: 'No'
    },
    {
    type: 'submit',
    name: 'yesButton',
    text: 'Yes',
    buttonType: 'primary'
    }
  ],
  onSubmit: (dialogApi) => {
    dialogApi.close();
    showEmail();
  }
  }
  return previewDialogConfig;
};

// This is the configuration of TinyMCE in this demo
tinymce.init({
  selector: 'textarea#comments-embedded',
  plugins: 'code tinycomments powerpaste advcode table save',
  toolbar: 'bold italic underline | addcomment showcomments | saveComments | code',

  // Comments mode, uses comments information as embedded into the document itself (as a comment tag)
  tinycomments_mode: 'embedded',
  
  // The currentAuthor object is as represented in the comments eventLog
  tinycomments_author: currentAuthor.author,
  tinycomments_author_name: currentAuthor.authorName,
  tinycomments_author_avatar: authorAvatars[currentAuthor.authorName],
  
  // These options define an author who is able to resolve/delete conversations
  tinycomments_can_resolve: (req, done, fail) => {
  const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor.author;
  done({ canResolve: allowed || currentAuthor.author === admin });
  },
  tinycomments_can_delete: (req, done, fail) => {
  const allowed = req.comments.length > 0 && req.comments[0].author === currentAuthor.author;
  done({ canDelete: allowed || currentAuthor.author === admin });
  },
  
  // These options are for style, read more about them in our documentation
  content_css: 'document',
  sidebar_show: 'showcomments',
  
  // The setup function allows direct use of APIs available via the `editor` object
  setup: (editor) => {
    // This function creates a button for retrieving and displaying the email dialog
    editor.ui.registry.addButton('saveComments', {
    icon: 'upload',
      text: 'Save',
      onAction: () => {
      // This function retrieves data from the eventLog API and returns a dialog config
    const eventLog = getEventLog(editor);

    /* This 'mceSave' command will save any changes made to the document, including any comments.
       Any update email should only be sent after saving the document and comments.
    */
    // editor.execCommand('mceSave');
    
    // Update the latest save time
    documentSaveTimestamp = new Date();
    
    // This function retrieves relevant data in a readable object
      const recipientData = getRecipientData(eventLog);


    // If there are recipients (conversations have been updated) ask to open a dialog to preview email notifications
    if (Object.keys(recipientData).length !== 0) {
      console.log(recipientData);
      
      editor.notificationManager.open({
      text: 'Your changes have been saved. Email notifications have been submitted successfully, and can be viewed via the console log.',
      timeout: 5000,
      type: 'success'
      });
      // Opens a dialog to ask the user whether they would like to see the emails
      editor.windowManager.open(
        getPreviewDialogConfig(() => {
        // Builds a dialog for displaying email content
        const emailDialogConfig = getEmailDialogConfig(recipientData);
        editor.windowManager.open(emailDialogConfig)
      })
      )
    } else {
      editor.notificationManager.open({
      text: 'Your changes have been saved.',
      timeout: 5000,
      type: 'success'
      });
    }
    }
    });
  // Read more about building custom toolbar buttons, dialogs, and other UI components in TinyMCE
  // at our documentation website https://www.tiny.cloud/docs/tinymce/6/
  }
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.2.0/tinymce.min.js
  2. https://cdn.tiny.cloud/1/qagffr3pkuv17a8on1afax661irst1hbr4e6tbv888sz91jc/tinymce/6/tinymce.min.js