TinyMCEPlugin.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <?php
  2. /**
  3. * StatusNet - the distributed open-source microblogging tool
  4. * Copyright (C) 2010, StatusNet, Inc.
  5. *
  6. * Use TinyMCE library to allow rich text editing in the browser
  7. *
  8. * PHP version 5
  9. *
  10. * This program is free software: you can redistribute it and/or modify
  11. * it under the terms of the GNU Affero General Public License as published by
  12. * the Free Software Foundation, either version 3 of the License, or
  13. * (at your option) any later version.
  14. *
  15. * This program is distributed in the hope that it will be useful,
  16. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. * GNU Affero General Public License for more details.
  19. *
  20. * You should have received a copy of the GNU Affero General Public License
  21. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  22. *
  23. * @category WYSIWYG
  24. * @package StatusNet
  25. * @author Evan Prodromou <evan@status.net>
  26. * @copyright 2010 StatusNet, Inc.
  27. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
  28. * @link http://status.net/
  29. */
  30. if (!defined('STATUSNET')) {
  31. // This check helps protect against security problems;
  32. // your code file can't be executed directly from the web.
  33. exit(1);
  34. }
  35. /**
  36. * Use TinyMCE library to allow rich text editing in the browser
  37. *
  38. * Converts the notice form in browser to a rich-text editor.
  39. *
  40. * FIXME: this plugin DOES NOT load its static files from the configured
  41. * plugin server if one exists. There are cross-server permissions errors
  42. * if you try to do that (something about window.tinymce).
  43. *
  44. * @category WYSIWYG
  45. * @package StatusNet
  46. * @author Evan Prodromou <evan@status.net>
  47. * @copyright 2010 StatusNet, Inc.
  48. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
  49. * @link http://status.net/
  50. */
  51. class TinyMCEPlugin extends Plugin
  52. {
  53. var $html;
  54. // By default, TinyMCE editor will be available to all users.
  55. // With restricted on, only users who have been granted the
  56. // "richedit" role get it.
  57. public $restricted = false;
  58. function onEndShowScripts($action)
  59. {
  60. if (common_logged_in() && $this->isAllowedRichEdit()) {
  61. $action->script(common_path('plugins/TinyMCE/js/jquery.tinymce.js'));
  62. $action->inlineScript($this->_inlineScript());
  63. }
  64. return true;
  65. }
  66. function onEndShowStyles($action)
  67. {
  68. if ($this->isAllowedRichEdit()) {
  69. $action->style('span#notice_data-text_container, span#notice_data-text_parent { float: left }');
  70. }
  71. return true;
  72. }
  73. function onPluginVersion(&$versions)
  74. {
  75. $versions[] = array('name' => 'TinyMCE',
  76. 'version' => GNUSOCIAL_VERSION,
  77. 'author' => 'Evan Prodromou',
  78. 'homepage' => 'http://status.net/wiki/Plugin:TinyMCE',
  79. 'rawdescription' =>
  80. // TRANS: Plugin description.
  81. _m('Use TinyMCE library to allow rich text editing in the browser.'));
  82. return true;
  83. }
  84. /**
  85. * Sanitize HTML input and strip out potentially dangerous bits.
  86. *
  87. * @param string $raw HTML
  88. * @return string HTML
  89. */
  90. private function sanitizeHtml($raw)
  91. {
  92. require_once INSTALLDIR . '/extlib/htmLawed/htmLawed.php';
  93. $config = array('safe' => 1,
  94. 'deny_attribute' => 'id,style,on*');
  95. return htmLawed($raw, $config);
  96. }
  97. /**
  98. * Hook for new-notice form processing to take our HTML goodies;
  99. * won't affect API posting etc.
  100. *
  101. * @param NewNoticeAction $action
  102. * @param User $user
  103. * @param string $content
  104. * @param array $options
  105. * @return boolean hook return
  106. */
  107. function onStartSaveNewNoticeWeb($action, $user, &$content, &$options)
  108. {
  109. if ($action->arg('richedit') && $this->isAllowedRichEdit()) {
  110. $html = $this->sanitizeHtml($content);
  111. $options['rendered'] = $html;
  112. $content = common_strip_html($html);
  113. }
  114. return true;
  115. }
  116. /**
  117. * Hook for new-notice form processing to process file upload appending...
  118. *
  119. * @param NewNoticeAction $action
  120. * @param MediaFile $media
  121. * @param string $content
  122. * @param array $options
  123. * @return boolean hook return
  124. */
  125. function onStartSaveNewNoticeAppendAttachment($action, $media, &$content, &$options)
  126. {
  127. if ($action->arg('richedit') && $this->isAllowedRichEdit()) {
  128. // See if we've got a placeholder inline image; if so, fill it!
  129. $dom = new DOMDocument();
  130. if ($dom->loadHTML($options['rendered'])) {
  131. $imgs = $dom->getElementsByTagName('img');
  132. foreach ($imgs as $img) {
  133. if (preg_match('/(^| )placeholder( |$)/', $img->getAttribute('class'))) {
  134. // Create a link to the attachment page...
  135. $this->formatAttachment($img, $media);
  136. }
  137. }
  138. $options['rendered'] = $this->saveHtml($dom);
  139. }
  140. // The regular code will append the short URL to the plaintext content.
  141. // Carry on and let it through...
  142. }
  143. return true;
  144. }
  145. /**
  146. * Format the attachment placeholder img with the final version.
  147. *
  148. * @param DOMElement $img
  149. * @param MediaFile $media
  150. */
  151. private function formatAttachment($img, $media)
  152. {
  153. $parent = $img->parentNode;
  154. $dom = $img->ownerDocument;
  155. $link = $dom->createElement('a');
  156. $link->setAttribute('href', $media->fileurl);
  157. $link->setAttribute('title', File::url($media->filename));
  158. if ($this->isEmbeddable($media)) {
  159. // Fix the the <img> attributes and wrap the link around it...
  160. $this->insertImage($img, $media);
  161. $parent->replaceChild($link, $img); //it dies in here?!
  162. $link->appendChild($img);
  163. } else {
  164. // Not an image? Replace it with a text link.
  165. $link->setAttribute('rel', 'external');
  166. $link->setAttribute('class', 'attachment');
  167. $link->setAttribute('id', 'attachment-' . $media->fileRecord->id);
  168. $text = $dom->createTextNode($media->shortUrl());
  169. $link->appendChild($text);
  170. $parent->replaceChild($link, $img);
  171. }
  172. }
  173. /**
  174. * Is this media file a type we can display inline?
  175. *
  176. * @param MediaFile $media
  177. * @return boolean
  178. */
  179. private function isEmbeddable($media)
  180. {
  181. $showable = array('image/png',
  182. 'image/gif',
  183. 'image/jpeg');
  184. return in_array($media->mimetype, $showable);
  185. }
  186. /**
  187. * Rewrite and resize a placeholder image element to match the uploaded
  188. * file. If the holder is smaller than the file, the file is scaled to fit
  189. * with correct aspect ratio (but will be loaded at full resolution).
  190. *
  191. * @param DOMElement $img
  192. * @param MediaFile $media
  193. */
  194. private function insertImage($img, $media)
  195. {
  196. $img->setAttribute('src', $media->fileRecord->url);
  197. $holderWidth = intval($img->getAttribute('width'));
  198. $holderHeight = intval($img->getAttribute('height'));
  199. $path = File::path($media->filename);
  200. $imgInfo = getimagesize($path);
  201. if ($imgInfo) {
  202. $origWidth = $imgInfo[0];
  203. $origHeight = $imgInfo[1];
  204. list($width, $height) = $this->sizeBox(
  205. $origWidth, $origHeight,
  206. $holderWidth, $holderHeight);
  207. $img->setAttribute('width', $width);
  208. $img->setAttribute('height', $height);
  209. }
  210. }
  211. /**
  212. *
  213. * @param int $origWidth
  214. * @param int $origHeight
  215. * @param int $holderWidth
  216. * @param int $holderHeight
  217. * @return array($width, $height)
  218. */
  219. private function sizeBox($origWidth, $origHeight, $holderWidth, $holderHeight)
  220. {
  221. $holderAspect = $holderWidth / $holderHeight;
  222. $origAspect = $origWidth / $origHeight;
  223. if ($origAspect >= 1.0) {
  224. // wide image
  225. if ($origWidth > $holderWidth) {
  226. return array($holderWidth, intval($holderWidth / $origAspect));
  227. } else {
  228. return array($origWidth, $origHeight);
  229. }
  230. } else {
  231. if ($origHeight > $holderHeight) {
  232. return array(intval($holderWidth * $origAspect), $holderHeight);
  233. } else {
  234. return array($origWidth, $origHeight);
  235. }
  236. }
  237. }
  238. private function saveHtml($dom)
  239. {
  240. $html = $dom->saveHTML();
  241. // hack to remove surrounding crap added to the dom
  242. // all we wanted was a fragment
  243. $stripped = preg_replace('/^.*<body[^>]*>(.*)<\/body.*$/is', '$1', $html);
  244. return $stripped;
  245. }
  246. function _inlineScript()
  247. {
  248. $path = common_path('plugins/TinyMCE/js/tiny_mce.js');
  249. $placeholder = common_path('plugins/TinyMCE/icons/placeholder.png');
  250. // Note: the normal on-submit triggering to save data from
  251. // the HTML editor into the textarea doesn't play well with
  252. // our AJAX form submission. Manually moving it to trigger
  253. // on our send button click.
  254. $scr = <<<END_OF_SCRIPT
  255. (function() {
  256. var origInit = SN.Init.NoticeFormSetup;
  257. SN.Init.NoticeFormSetup = function(form) {
  258. origInit(form);
  259. var noticeForm = form;
  260. var textarea = form.find('.notice_data-text');
  261. if (textarea.length == 0) return;
  262. textarea.tinymce({
  263. script_url : '{$path}',
  264. // General options
  265. theme : "advanced",
  266. plugins : "paste,fullscreen,autoresize,inlinepopups,tabfocus,linkautodetect",
  267. theme_advanced_buttons1 : "bold,italic,strikethrough,|,undo,redo,|,link,unlink,image,|,fullscreen",
  268. theme_advanced_buttons2 : "",
  269. theme_advanced_buttons3 : "",
  270. add_form_submit_trigger : false,
  271. theme_advanced_resizing : true,
  272. tabfocus_elements: ":prev,:next",
  273. setup: function(ed) {
  274. noticeForm.append('<input type="hidden" name="richedit" value="1">');
  275. form.find('.submit:first').click(function() {
  276. tinymce.triggerSave();
  277. });
  278. var origCounter = SN.U.CharacterCount;
  279. SN.U.CharacterCount = function(form) {
  280. var text = $(ed.getDoc()).text();
  281. return text.length;
  282. };
  283. ed.onKeyUp.add(function (ed, e) {
  284. SN.U.Counter(noticeForm);
  285. });
  286. form.find('input[type=file]').change(function() {
  287. var img = '<img src="{$placeholder}" class="placeholder" width="320" height="240">';
  288. var html = tinyMCE.activeEditor.getContent();
  289. ed.setContent(html + img);
  290. });
  291. }
  292. });
  293. };
  294. })();
  295. END_OF_SCRIPT;
  296. return $scr;
  297. }
  298. /**
  299. * Does the current user have permission to use the rich-text editor?
  300. * Always true unless the plugin's "restricted" setting is on, in which
  301. * case it's limited to users with the "richedit" role.
  302. *
  303. * @fixme make that more sanely configurable :)
  304. *
  305. * @return boolean
  306. */
  307. private function isAllowedRichEdit()
  308. {
  309. if ($this->restricted) {
  310. $user = common_current_user();
  311. return !empty($user) && $user->hasRole('richedit');
  312. } else {
  313. return true;
  314. }
  315. }
  316. }