Source: editor-menu.js

  1. /* global $, libreviews, config */
  2. const {
  3. wrapItem,
  4. blockTypeItem,
  5. Dropdown,
  6. DropdownSubmenu,
  7. joinUpItem,
  8. liftItem,
  9. undoItem,
  10. redoItem,
  11. icons,
  12. MenuItem
  13. } = require("prosemirror-menu");
  14. const unescapeHTML = require('unescape-html');
  15. // Load proper translations for built-in items
  16. undoItem.spec.title = libreviews.msg('undo');
  17. redoItem.spec.title = libreviews.msg('redo');
  18. joinUpItem.spec.title = libreviews.msg('join with item above');
  19. liftItem.spec.title = libreviews.msg('decrease item indentation');
  20. const { NodeSelection, TextSelection } = require('prosemirror-state');
  21. const { toggleMark, wrapIn } = require('prosemirror-commands');
  22. const { wrapInList } = require('prosemirror-schema-list');
  23. const { TextField, openPrompt } = require('./editor-prompt');
  24. const { uploadModal } = require('./upload-modal');
  25. const { guessMediaType } = require('markdown-it-html5-media');
  26. // Helpers to create specific types of items
  27. function canInsert(state, nodeType, attrs) {
  28. let $from = state.selection.$from;
  29. for (let d = $from.depth; d >= 0; d--) {
  30. let index = $from.index(d);
  31. if ($from.node(d).canReplaceWith(index, index, nodeType, attrs))
  32. return true;
  33. }
  34. return false;
  35. }
  36. function insertMediaItem(nodeTypes, schema) {
  37. return new MenuItem({
  38. title: libreviews.msg('insert media help'),
  39. label: libreviews.msg('insert media'),
  40. select(state) {
  41. return canInsert(state, nodeTypes.image);
  42. },
  43. run(state, _dispatch, view) {
  44. let { from, to } = state.selection,
  45. attrs = null,
  46. showCaptionField = true;
  47. // Extract attributes from any media selection. We apply ALT text from
  48. // images to video/audio descriptions and vice versa
  49. if (state.selection instanceof NodeSelection) {
  50. switch (state.selection.node.type.name) {
  51. case 'image':
  52. attrs = {
  53. src: state.selection.node.attrs.src,
  54. alt: state.selection.node.attrs.alt ||
  55. state.selection.node.attrs.description || null
  56. };
  57. break;
  58. case 'video':
  59. case 'audio':
  60. attrs = {
  61. src: state.selection.node.attrs.src,
  62. description: state.selection.node.attrs.description ||
  63. state.selection.node.attrs.alt || null
  64. };
  65. break;
  66. default:
  67. // No default
  68. }
  69. showCaptionField = false;
  70. }
  71. const fields = {};
  72. fields.src = new TextField({ label: libreviews.msg('media url'), required: true, value: attrs && attrs.src });
  73. if (showCaptionField)
  74. fields.caption = new TextField({
  75. label: libreviews.msg('caption label'),
  76. });
  77. fields.alt = new TextField({
  78. label: libreviews.msg('media alt text'),
  79. value: attrs ? attrs.alt || attrs.description : state.doc.textBetween(from, to, " ")
  80. });
  81. openPrompt({
  82. view,
  83. fields,
  84. title: libreviews.msg('insert media dialog title'),
  85. callback(attrs) {
  86. const nodeType = guessMediaType(attrs.src);
  87. // <video>/<audio> tags do not support ALT; the text is rendered
  88. // as inner HTML alongside the fallback message.
  89. if (['video', 'audio'].includes(nodeType)) {
  90. attrs.description = attrs.alt;
  91. Reflect.deleteProperty(attrs, 'alt');
  92. }
  93. let tr = view.state.tr.replaceSelectionWith(nodeTypes[nodeType].createAndFill(attrs));
  94. if (attrs.caption && attrs.caption.length)
  95. tr = addCaption({ description: attrs.caption + '\n', schema, state: view.state, transaction: tr });
  96. view.dispatch(tr);
  97. view.focus();
  98. }
  99. });
  100. }
  101. });
  102. }
  103. function addCaption({ description, schema, state, transaction }) {
  104. const br = schema.nodes.hard_break.create(),
  105. descriptionNode = schema.text(description,
  106. schema.marks.strong.create()),
  107. pos = state.selection.$anchor.pos;
  108. return transaction
  109. .insert(pos + 1, br)
  110. .insert(pos + 2, descriptionNode);
  111. }
  112. function horizontalRuleItem(hr) {
  113. return new MenuItem({
  114. title: libreviews.msg('insert horizontal rule help', { accessKey: '_' }),
  115. label: libreviews.msg('insert horizontal rule'),
  116. select(state) {
  117. return canInsert(state, hr);
  118. },
  119. run(state, dispatch) {
  120. dispatch(state.tr.replaceSelectionWith(hr.create()));
  121. }
  122. });
  123. }
  124. function cmdItem(cmd, options) {
  125. let passedOptions = {
  126. label: options.title,
  127. run: cmd,
  128. select(state) {
  129. return cmd(state);
  130. }
  131. };
  132. for (let prop in options)
  133. passedOptions[prop] = options[prop];
  134. return new MenuItem(passedOptions);
  135. }
  136. function markActive(state, type) {
  137. let { from, $from, to, empty } = state.selection;
  138. if (empty)
  139. return type.isInSet(state.storedMarks || $from.marks());
  140. else
  141. return state.doc.rangeHasMark(from, to, type);
  142. }
  143. function markItem(markType, options) {
  144. let passedOptions = {
  145. active(state) {
  146. return markActive(state, markType);
  147. }
  148. };
  149. for (let prop in options)
  150. passedOptions[prop] = options[prop];
  151. return cmdItem(toggleMark(markType), passedOptions);
  152. }
  153. function fullScreenItem() {
  154. return new MenuItem({
  155. title: libreviews.msg('full screen mode', { accessKey: 'u' }),
  156. icon: { dom: $('<span class="fa fa-arrows-alt baselined-icon"></span>')[0] },
  157. active() {
  158. return this.enabled || false;
  159. },
  160. run(state, _dispatch, view) {
  161. let $rteContainer = $(view.dom).closest('.rte-container');
  162. let id = Number($rteContainer[0].id.match(/\d+/)[0]);
  163. if (!this.enabled) {
  164. libreviews.activeRTEs[id].enterFullScreen();
  165. this.enabled = true;
  166. } else {
  167. libreviews.activeRTEs[id].exitFullScreen();
  168. this.enabled = false;
  169. }
  170. view.updateState(state);
  171. }
  172. });
  173. }
  174. function uploadModalItem(mediaNodes, schema) {
  175. return new MenuItem({
  176. title: libreviews.msg('upload and insert media'),
  177. icon: { dom: $('<span class="fa fa-cloud-upload baselined-icon"><span>')[0] },
  178. active() {
  179. return false;
  180. },
  181. run(state, dispatch, view) {
  182. // For some forms, we submit uploaded file IDs so they can be processed
  183. // server-side
  184. const $form = $(view.dom).closest('form[data-submit-uploaded-files]');
  185. uploadModal(uploads => {
  186. const upload = uploads[0];
  187. const attrs = {
  188. src: `/static/uploads/${encodeURIComponent(upload.uploadedFileName)}`
  189. };
  190. const nodeType = guessMediaType(attrs.src);
  191. const description = generateDescriptionFromUpload(upload);
  192. let tr = state.tr
  193. .replaceSelectionWith(mediaNodes[nodeType].createAndFill(attrs));
  194. tr = addCaption({ description, schema, state, transaction: tr });
  195. dispatch(tr);
  196. if ($form.length) {
  197. $form.append(`<input type="hidden" ` +
  198. ` name="uploaded-file-${upload.fileID}" value="1">`);
  199. if ($form.find('#social-media-image-select').length) {
  200. let summarizedDesc = upload.description[config.language].substr(0, 80);
  201. if (upload.description[config.language].length > 80)
  202. summarizedDesc += '...';
  203. $('#social-media-image-select').append(`<option value="${upload.fileID}">` +
  204. `${upload.uploadedFileName}: ${summarizedDesc}` +
  205. `</option>`);
  206. }
  207. }
  208. view.focus();
  209. });
  210. }
  211. });
  212. }
  213. function generateDescriptionFromUpload(upload) {
  214. // API returns escaped HTML; editor will re-escape it
  215. const description = unescapeHTML(upload.description[config.language]);
  216. const creator = upload.creator && upload.creator[config.language];
  217. let license;
  218. switch (upload.license) {
  219. case 'fair-use':
  220. license = libreviews.msg('fair use in caption');
  221. break;
  222. case 'cc-0':
  223. license = libreviews.msg('public domain in caption');
  224. break;
  225. default:
  226. license = libreviews.msg('license in caption', {
  227. stringParam: libreviews.msg(`${upload.license} short`)
  228. });
  229. }
  230. let rights;
  231. if (!creator) // Own work
  232. rights = libreviews.msg('rights in caption, own work', { stringParam: license });
  233. else
  234. rights = libreviews.msg('rights in caption, someone else\'s work', {
  235. stringParams: [creator, license]
  236. });
  237. const caption = libreviews.msg('caption', { stringParams: [description, rights] });
  238. // Final newline is important to ensure resulting markdown is parsed correctly
  239. return caption + '\n';
  240. }
  241. function formatCustomWarningItem(nodeType) {
  242. return new MenuItem({
  243. title: libreviews.msg('format as custom warning help'),
  244. label: libreviews.msg('format as custom warning'),
  245. run(state, dispatch, view) {
  246. let prompt = {
  247. view,
  248. title: libreviews.msg('format as custom warning dialog title'),
  249. fields: {
  250. message: new TextField({
  251. label: libreviews.msg('custom warning text'),
  252. required: true
  253. })
  254. },
  255. callback(attrs) {
  256. // Used to translate node back into markdown
  257. attrs.markup = `warning ${attrs.message}`;
  258. wrapIn(nodeType, attrs)(state, dispatch);
  259. view.focus();
  260. }
  261. };
  262. openPrompt(prompt);
  263. },
  264. select(state) {
  265. return wrapIn(nodeType)(state);
  266. }
  267. });
  268. }
  269. function linkItem(schema) {
  270. return new MenuItem({
  271. title: libreviews.msg('add or remove link', { accessKey: 'k' }),
  272. icon: icons.link,
  273. active(state) {
  274. return markActive(state, schema.marks.link);
  275. },
  276. run(state, dispatch, view) {
  277. if (markActive(state, schema.marks.link)) {
  278. toggleMark(schema.marks.link)(state, dispatch);
  279. return true;
  280. }
  281. const required = true;
  282. const fields = {
  283. href: new TextField({
  284. label: libreviews.msg('web address'),
  285. required,
  286. clean: val => !/^https?:\/\//i.test(val) ? 'http://' + val : val
  287. })
  288. };
  289. // User has not selected any text, so needs to provide it via dialog
  290. if (view.state.selection.empty)
  291. fields.linkText = new TextField({
  292. label: libreviews.msg('link text'),
  293. required,
  294. clean: val => val.trim()
  295. });
  296. openPrompt({
  297. view,
  298. title: libreviews.msg('add link dialog title'),
  299. fields,
  300. callback(attrs) {
  301. if (!attrs.linkText) {
  302. // Transform selected text into link
  303. toggleMark(schema.marks.link, attrs)(view.state, view.dispatch);
  304. // Advance cursor to end of selection (not necessarily head,
  305. // depending on selection direction)
  306. let rightmost = view.state.selection.$anchor.pos > view.state.selection.$head.pos ?
  307. view.state.selection.$anchor : view.state.selection.$head;
  308. view.dispatch(view.state.tr.setSelection(TextSelection.between(rightmost, rightmost)));
  309. // Disable link mark so user can now type normally again
  310. toggleMark(schema.marks.link, attrs)(view.state, view.dispatch);
  311. } else {
  312. view.dispatch(
  313. view.state.tr
  314. .replaceSelectionWith(schema.text(attrs.linkText))
  315. .addMark(view.state.selection.$from.pos,
  316. view.state.selection.$from.pos + attrs.linkText.length,
  317. schema.marks.link.create({ href: attrs.href }))
  318. );
  319. }
  320. view.focus();
  321. }
  322. });
  323. }
  324. });
  325. }
  326. function wrapListItem(nodeType, options) {
  327. return cmdItem(wrapInList(nodeType, options.attrs), options);
  328. }
  329. function headingItems(nodeType) {
  330. const headingItems = [];
  331. for (let i = 1; i <= 6; i++)
  332. headingItems[i - 1] = blockTypeItem(nodeType, {
  333. title: libreviews.msg('format as level heading help', { accessKey: String(i), numberParam: i }),
  334. label: libreviews.msg('format as level heading', { numberParam: i }),
  335. attrs: { level: i }
  336. });
  337. return headingItems;
  338. }
  339. /**
  340. * Build a menu for nodes and marks supported in the markdown schema.
  341. *
  342. * @param {Schema} schema
  343. * the markdown schema
  344. * @returns {Object}
  345. * the generated menu and all its items
  346. */
  347. function buildMenuItems(schema) {
  348. const mediaNodes = {
  349. image: schema.nodes.image,
  350. video: schema.nodes.video,
  351. audio: schema.nodes.audio
  352. };
  353. const items = {
  354. toggleStrong: markItem(schema.marks.strong, { title: libreviews.msg('toggle bold', { accessKey: 'b' }), icon: icons.strong }),
  355. toggleEm: markItem(schema.marks.em, { title: libreviews.msg('toggle italic', { accessKey: 'i' }), icon: icons.em }),
  356. toggleCode: markItem(schema.marks.code, { title: libreviews.msg('toggle code', { accessKey: '`' }), icon: icons.code }),
  357. toggleLink: linkItem(schema),
  358. insertMedia: insertMediaItem(mediaNodes, schema),
  359. insertHorizontalRule: horizontalRuleItem(schema.nodes.horizontal_rule),
  360. wrapBulletList: wrapListItem(schema.nodes.bullet_list, {
  361. title: libreviews.msg('format as bullet list', { accessKey: '8' }),
  362. icon: icons.bulletList
  363. }),
  364. wrapOrderedList: wrapListItem(schema.nodes.ordered_list, {
  365. title: libreviews.msg('format as numbered list', { accessKey: '9' }),
  366. icon: icons.orderedList
  367. }),
  368. wrapBlockQuote: wrapItem(schema.nodes.blockquote, {
  369. title: libreviews.msg('format as quote', { accessKey: '>' }),
  370. icon: icons.blockquote
  371. }),
  372. makeParagraph: blockTypeItem(schema.nodes.paragraph, {
  373. title: libreviews.msg('format as paragraph help', { accessKey: '0' }),
  374. label: libreviews.msg('format as paragraph')
  375. }),
  376. makeCodeBlock: blockTypeItem(schema.nodes.code_block, {
  377. title: libreviews.msg('format as code block help'),
  378. label: libreviews.msg('format as code block')
  379. }),
  380. formatSpoilerWarning: wrapItem(schema.nodes.container_warning, {
  381. title: libreviews.msg('format as spoiler help'),
  382. label: libreviews.msg('format as spoiler'),
  383. attrs: { markup: 'spoiler', message: libreviews.msg('spoiler warning') }
  384. }),
  385. formatNSFWWarning: wrapItem(schema.nodes.container_warning, {
  386. title: libreviews.msg('format as nsfw help'),
  387. label: libreviews.msg('format as nsfw'),
  388. attrs: { markup: 'nsfw', message: libreviews.msg('nsfw warning') }
  389. }),
  390. formatCustomWarning: formatCustomWarningItem(schema.nodes.container_warning),
  391. makeHeading: headingItems(schema.nodes.heading),
  392. fullScreen: fullScreenItem(),
  393. undo: undoItem,
  394. redo: redoItem,
  395. joinUp: joinUpItem,
  396. lift: liftItem
  397. };
  398. // Only trusted users can upload files directly from within the RTE.
  399. if (config.isTrusted)
  400. items.upload = uploadModalItem(mediaNodes, schema);
  401. const insertDropdown = new Dropdown([items.insertMedia, items.insertHorizontalRule], {
  402. label: libreviews.msg('insert'),
  403. title: libreviews.msg('insert help')
  404. });
  405. const headingSubmenu = new DropdownSubmenu([...items.makeHeading], { label: libreviews.msg('format as heading') });
  406. const typeDropdown = new Dropdown(
  407. [
  408. items.makeParagraph, items.makeCodeBlock, items.formatSpoilerWarning,
  409. items.formatNSFWWarning, items.formatCustomWarning, headingSubmenu
  410. ], {
  411. label: libreviews.msg('format block'),
  412. title: libreviews.msg('format block help')
  413. });
  414. // Create final menu structure. In the rendered menu, there is a separator
  415. // symbol between each array
  416. const mediaOptions = [insertDropdown];
  417. // Only trusted users can upload files directly via the RTE.
  418. if (config.isTrusted)
  419. mediaOptions.push(items.upload);
  420. const menu = [
  421. [items.toggleStrong, items.toggleEm, items.toggleCode, items.toggleLink],
  422. mediaOptions,
  423. [typeDropdown, items.wrapBulletList, items.wrapOrderedList, items.wrapBlockQuote, items.joinUp, items.lift],
  424. [items.undo, items.redo],
  425. [items.fullScreen]
  426. ];
  427. // We expose the items object so it can be used to externally trigger a menu
  428. // function, e.g., via a keyboard shortcut
  429. return { menu, items };
  430. }
  431. exports.buildMenuItems = buildMenuItems;