Kanka Editor Toolkit

Adds toolbar buttons to Summernote to quickly insert custom HTML elements or classes.

  1. // ==UserScript==
  2. // @name Kanka Editor Toolkit
  3. // @namespace http://tampermonkey.net/
  4. // @version 15
  5. // @description Adds toolbar buttons to Summernote to quickly insert custom HTML elements or classes.
  6. // @author Salvatos
  7. // @match https://app.kanka.io/*
  8. // @icon https://www.google.com/s2/favicons?domain=kanka.io
  9. // @grant GM_addStyle
  10. // @run-at document-end
  11. // ==/UserScript==
  12.  
  13. GM_addStyle(`
  14. /* Avoid right screen edge on dropdowns*/
  15. .note-marketplace .dropdown-menu {
  16. left: unset;
  17. right: 0;
  18. }
  19. .scrollable-menu {
  20. height: auto;
  21. max-height: 75vh;
  22. min-width: 200px !important;
  23. max-width: 30vw;
  24. overflow-x: hidden;
  25. }
  26. .scrollable-menu li a {
  27. padding: 0 5px;
  28. font-family: "Roboto", monospace;
  29. font-size: 13px;
  30. }
  31. .scrollable-menu li.class-group a {
  32. font-weight: bold;
  33. font-size: 13px;
  34. color: var(--box-header-text);
  35. }
  36. .class-group {
  37. text-align: center;
  38. background: hsl(var(--b3));
  39. border-radius: 2px;
  40. border: 1px solid #090e572b;
  41. }
  42. li.class-group a:hover {
  43. background: hsl(var(--p));
  44. }
  45. .note-editor.note-frame .note-status-output {
  46. padding: 0;
  47. height: auto;
  48. background-color: #f4f4f4;
  49. transition: height 2s;
  50. }
  51. .note-editor.note-frame .note-status-output .fadeout {
  52. display: block;
  53. padding: 5px 10px;
  54. width: 100%;
  55. background-color: #b34e4e;
  56. color: #eee;
  57. font-weight: bold;
  58. text-align: center
  59. }
  60.  
  61. /* Modals */
  62. :is(.tooltip-dialog, .easytabs-dialog) output {
  63. color: hsl(var(--bc));
  64. }
  65. :is(.tooltip-dialog, .easytabs-dialog) .btn2 {
  66. min-height: unset;
  67. padding: 10px 10px;
  68. text-transform: initial;
  69. }
  70.  
  71. /* ExT Helper */
  72. .tooltip-dialog .modal-content {
  73. max-height: 80vh;
  74. overflow-y: auto;
  75. }
  76. .tooltip-dialog .modal-body {
  77. display: grid;
  78. grid-template-columns: 70% 30%;
  79. }
  80. .tooltip-dialog .modal-footer {
  81. text-align: left;
  82. }
  83. .tooltip-dialog {
  84. display: none;
  85. padding-right: 12px
  86. }
  87. .tooltip-dialog h5 {
  88. font-size: 15px;
  89. }
  90. .tooltip-dialog label em {
  91. font-weight: 300;
  92. }
  93. .tooltip-dialog label {
  94. font-size: 13px;
  95. display: inline;
  96. }
  97. #tooltip-demo {
  98. align-self: center;
  99. }
  100. #tooltip-demo output {
  101. padding: 10px;
  102. margin-bottom: 10px;
  103. border: 1px solid grey;
  104. border-radius: 5px;
  105. }
  106. #tooltip-demo :is(.ExT-wrap, .ExT-attribute) {
  107. color: whitesmoke;
  108. }
  109.  
  110. /* Easy Tabs Helper */
  111. .easytabs-dialog .modal-footer {
  112. display: flex;
  113. gap: 10px;
  114. align-items: center;
  115. justify-content: end;
  116. padding: 10px 0;
  117. }
  118. .easytabs-dialog p {
  119. margin-bottom: 10px;
  120. }
  121. #easytabs-items {
  122. padding-top: 0;
  123. }
  124. #easytabs-items .easytabs-content {
  125. display: none;
  126. }
  127. #easytabs-items .easytabs-toggles {
  128. display: flex;
  129. gap: 0 5px;
  130. }
  131. #easytabs-items .easytabs-tab {
  132. font-size: unset;
  133. }
  134. .easytabs-dragger {
  135. display: flex;
  136. align-items: center;
  137. gap: 0 5px;
  138. }
  139. .easytabs-select {
  140. max-width: 30%;
  141. padding: 8px 5px;
  142. }
  143. `);
  144.  
  145. // Wait for Summernote to initialize
  146. $('.html-editor').on('summernote.init', function() {
  147.  
  148. // Prepare to check for supported themes in the campaign
  149. var rootFlags = getComputedStyle(document.documentElement);
  150.  
  151. /* HTML INSERTER ARRAYS */
  152. // Define our supported code snippets
  153. let snippetArray = [];
  154.  
  155. /* Campaign-specific custom HTML shortcuts */
  156. if (rootFlags.getPropertyValue('--summernote-insert-html-shortcuts')) {
  157. let customShortcuts = rootFlags.getPropertyValue('--summernote-insert-html-shortcuts').split(" ");
  158. customShortcuts.forEach(child => {
  159. let customHTML = rootFlags.getPropertyValue('--summernote-insert-html-'+child).replace(/(?<!\\)\[/g, "<").replace(/(?<!\\)\]/g, ">").replace(/\\/g, "").split("|||");
  160. snippetArray.push(
  161. {"listing": customHTML[0], "code": customHTML[1]}
  162. );
  163. });
  164. }
  165.  
  166. // Default: Details/summary combo
  167. snippetArray.push(
  168. {"listing": "Spoiler block (<b>warning</b>:<br />safer to edit in HTML view)", "code": '<details><summary>Summary</summary>Spoiler text</details>'}
  169. );
  170.  
  171. // Easy Tabs by Salvatos
  172. // TODO: Make a separate button for this?
  173. if (rootFlags.getPropertyValue('--summernote-insert-easytabs')) {
  174. snippetArray.push(
  175. {"listing": "Easy Tabs Helper (visual editor only)", "code": ''}
  176. );
  177. }
  178.  
  179. // Extraordinary Tooltips by Salvatos
  180. // TODO: Make a separate button for this?
  181. if (rootFlags.getPropertyValue('--summernote-insert-extraordinary-tooltips')) {
  182. snippetArray.push(
  183. {"listing": "Extraordinary Tooltips Helper", "code": ''}
  184. );
  185. }
  186.  
  187. // Figure Box and Floats by Ornstein
  188. if (rootFlags.getPropertyValue('--summernote-insert-figure-box')) {
  189. snippetArray.push(
  190. {"listing": "Figure Box (base)", "code": '<div class="figure">Insert image and caption here</div>'}
  191. );
  192. }
  193.  
  194. // Responsive Image Gallery by Salvatos
  195. if (rootFlags.getPropertyValue('--summernote-insert-autogallery')) {
  196. snippetArray.push(
  197. {"listing": "Responsive Image Gallery", "code": '<div class="autogallery">Insert gallery images here</div>'}
  198. );
  199. }
  200.  
  201. // Simple Tooltips by KeepOnScrollin
  202. if (rootFlags.getPropertyValue('--summernote-insert-simple-tooltip')) {
  203. snippetArray.push(
  204. {"listing": "Simple Tooltips (default &ndash; top)", "code": '<span class="simple-tooltip">Tooltip trigger<span class="simple-tooltip-text">Tooltip content</span></span>'}
  205. );
  206. }
  207.  
  208. // Build list items for our supported plugin snippets
  209. var themeList = "";
  210. for (let i = 0; i < snippetArray.length; i++) {
  211. // Add an id to ExT Helper to trigger a different event
  212. if (snippetArray[i]["listing"] == "Extraordinary Tooltips Helper") {
  213. themeList += '<li id="ExT-Helper" data-helper="true" aria-label="' + snippetArray[i]["listing"] + '"><a href="#_">' + snippetArray[i]["listing"] + '</a></li>';
  214. }
  215. // Add an id to Easy Tabs Helper to trigger a different event
  216. else if (snippetArray[i]["listing"] == "Easy Tabs Helper (visual editor only)") {
  217. themeList += '<li id="Easytabs-Helper" data-helper="true" aria-label="' + snippetArray[i]["listing"] + '"><a href="#_">' + snippetArray[i]["listing"] + '</a></li>';
  218. }
  219. else {
  220. themeList += '<li aria-label="' + snippetArray[i]["listing"] + '"><a href="#_">' + snippetArray[i]["listing"] + '</a></li>';
  221. }
  222. }
  223.  
  224. /* CLASS INSERTER ARRAYS */
  225. // Define and group supported classes
  226. let classArray = [];
  227. // Some useful Bootstrap and Kanka-native classes
  228. classArray.push(
  229. {"name": "delimiter:Kanka", "hint": "#_"},
  230. {"name": "pull-left", "hint": "Floats the element to the left"},
  231. {"name": "pull-right", "hint": "Floats the element to the right"},
  232. {"name": "center-block", "hint": "Centers images and block elements"}
  233. );
  234. // Grab any custom classes set up by the campaign specifically to be used here
  235. var customClasses = rootFlags.getPropertyValue('--summernote-insert-custom-classes');
  236. if (customClasses) {
  237. classArray.push(
  238. {"name": "delimiter:Campaign classes", "hint": ""}
  239. );
  240. // Strip quotes and make a space-separated array
  241. var customClassList = customClasses.substring(1, customClasses.length - 1).split(" ");
  242. customClassList.forEach(child => {
  243. classArray.push(
  244. {"name": child, "hint": ""}
  245. );
  246. });
  247. }
  248. // .boxquote by Olessan
  249. if (rootFlags.getPropertyValue('--summernote-insert-olessan-boxquote')) {
  250. classArray.push(
  251. {"name": "delimiter:.boxquote", "hint": "https://marketplace.kanka.io/plugins/6cfb03a0-9f80-4743-9743-bd4c2b05bea2"},
  252. {"name": "boxquote", "hint": "Applies different styling to a boxquote element"}
  253. );
  254. }
  255. // Context-Aware Classes by Salvatos
  256. if (rootFlags.getPropertyValue('--summernote-insert-context-aware-classes')) {
  257. classArray.push(
  258. {"name": "delimiter:Context-Aware Classes", "hint": "https://marketplace.kanka.io/plugins/31910211-f33b-47b4-8db2-47dfa8dc959e"},
  259. {"name": "dashboard-only", "hint": "Only displays the element on a dashboard"},
  260. {"name": "no-dashboard", "hint": "Hides the element from dashboards"},
  261. {"name": "editor-only", "hint": "Only displays the element in Summernote"},
  262. {"name": "ExT-only", "hint": "Only displays the element in Extraordinary Tooltips"},
  263. {"name": "no-ExT", "hint": "Hides the element from Extraordinary Tooltips"},
  264. {"name": "mobile-only", "hint": "Only displays the element at small resolutions"},
  265. {"name": "no-mobile", "hint": "Only displays the element at high resolutions"},
  266. {"name": "marker-only", "hint": "Only displays the element in map markers"},
  267. {"name": "marker-details-only", "hint": "Only displays the element in a map's sidebar"},
  268. {"name": "no-map", "hint": "Hides the elements from maps and map widgets"},
  269. {"name": "print-only", "hint": "Only displays the element in printed media"},
  270. {"name": "no-print", "hint": "Removes the element from printed media"},
  271. {"name": "tooltip-only", "hint": "Only displays the element in tooltips"},
  272. {"name": "no-tooltip", "hint": "Removes the element from tooltips"},
  273. {"name": "transclusion-only", "hint": "Only displays the element when the entry is transcluded into another entity"},
  274. {"name": "no-transclusion", "hint": "Removes the element from transcluded entries"}
  275. );
  276. }
  277. // Extraordinary Tooltips by Salvatos
  278. if (rootFlags.getPropertyValue('--summernote-insert-extraordinary-tooltips')) {
  279. classArray.push(
  280. {"name": "delimiter:Extraordinary Tooltips", "hint": "https://marketplace.kanka.io/plugins/31910211-f33b-47b4-8db2-47dfa8dc959e"},
  281. {"name": "ExT-inline", "hint": "For the paragraphs surrounding an inline ExT"}
  282. );
  283. }
  284. // Figure Box and Floats by Ornstein
  285. if (rootFlags.getPropertyValue('--summernote-insert-figure-box')) {
  286. classArray.push(
  287. {"name": "delimiter:Figure Box and Floats", "hint": "https://marketplace.kanka.io/plugins/a0a51dda-6a69-4e05-96a9-5fda05f160e5"},
  288. {"name": "l", "hint": "Left float"},
  289. {"name": "r", "hint": "Right float"},
  290. {"name": "clear", "hint": "Reset float (break line)"}
  291. );
  292. }
  293. // Handwritten Journal by Ornstein
  294. if (rootFlags.getPropertyValue('--summernote-insert-handwritten-journal')) {
  295. classArray.push(
  296. {"name": "delimiter:Handwritten Journal", "hint": "https://marketplace.kanka.io/plugins/bce5fcec-b279-4b44-ac68-273e65d30ab6"},
  297. {"name": "hand1", "hint": "Kalam font"},
  298. {"name": "hand2", "hint": "Sacramento font"},
  299. {"name": "hand3", "hint": "Dancing Script font"},
  300. {"name": "hand4", "hint": "Fondamento font"},
  301. {"name": "hand5", "hint": "Homemade Apple font"},
  302. {"name": "hand6", "hint": "Shadows Into Light font"},
  303. {"name": "lback", "hint": "Parchment background"},
  304. {"name": "letter", "hint": "Prerequisite for other classes"},
  305. {"name": "sig", "hint": "Double font size"}
  306. );
  307. }
  308. // Redacted Text by Salvatos
  309. if (rootFlags.getPropertyValue('--summernote-insert-salv-redacted')) {
  310. classArray.push(
  311. {"name": "delimiter:Redacted Text", "hint": "https://marketplace.kanka.io/plugins/810f9079-6a14-4267-a9f1-a7a142410116"},
  312. {"name": "salv-redacted", "hint": "Makes text black on black with a &quot;redacted&quot; annotation"}
  313. );
  314. }
  315. // Simple Tooltips by KeepOnScrollin
  316. if (rootFlags.getPropertyValue('--summernote-insert-simple-tooltip')) {
  317. classArray.push(
  318. {"name": "delimiter:Simple Tooltips", "hint": "https://marketplace.kanka.io/plugins/1bb9b54c-46ef-4bd9-b6d3-8e88bcb99e91"},
  319. {"name": "top", "hint": "Display tooltip above trigger"},
  320. {"name": "bottom", "hint": "Display tooltip below trigger"},
  321. {"name": "left", "hint": "Display tooltip left of trigger"},
  322. {"name": "right", "hint": "Display tooltip right of trigger"}
  323. );
  324. }
  325. // Tip Box by Critter
  326. if (rootFlags.getPropertyValue('--summernote-insert-tip-box')) {
  327. classArray.push(
  328. {"name": "delimiter:Tip Box", "hint": "https://marketplace.kanka.io/plugins/c16e9e7f-8e82-4836-84eb-cdcbf66b8bdd"},
  329. {"name": "tipbox-big", "hint": "Creates a wide, bordered, right-floating container"},
  330. {"name": "tipbox-small", "hint": "Creates a narrow, bordered, right-floating container"}
  331. );
  332. }
  333.  
  334. // Build list items for our supported plugin classes
  335. var classList = "";
  336. for (let i = 0; i < classArray.length; i++) {
  337. if (classArray[i]["name"].match(/delimiter:/g)) {
  338. classList += '<li class="class-group" aria-label="' + classArray[i]["name"].split(":")[1] + '">';
  339. if (classArray[i]["name"].split(":")[1] == "Bootstrap") {
  340. classList += '<a title="Provided with Kanka" href="#_">';
  341. }
  342. else if (classArray[i]["name"].split(":")[1] == "Campaign classes") {
  343. classList += '<a title="Provided by the campaign" href="#_">';
  344. }
  345. else {
  346. classList += '<a title="Open documentation in a new tab" target="_blank" href="' + classArray[i]["hint"] + '">';
  347. }
  348. classList += classArray[i]["name"].split(":")[1] + '</a></li>';
  349. }
  350. else {
  351. classList += '<li aria-label="' + classArray[i]["name"] + '" title="' + classArray[i]["hint"] + '"><a href="#_">' + classArray[i]["name"] + '</a></li>';
  352. }
  353. }
  354.  
  355. // Locate toolbar and insert our dropdown buttons
  356. const toolbar = document.getElementsByClassName('note-toolbar')[0];
  357.  
  358. var buttons = `<div class="note-btn-group btn-group note-marketplace">`;
  359. var snippetsButton = `
  360. <div class="note-btn-group btn-group">
  361. <button type="button" class="btn btn-default btn-sm dropdown-toggle note-codeview-keep" tabindex="-1" data-toggle="dropdown" title="Custom HTML blocks" aria-expanded="false">
  362. <i class="fas fa-puzzle-piece"></i> <span class="note-icon-caret"></span>
  363. </button>
  364. <ul class="note-dropdown-menu dropdown-menu dropdown-snippets" aria-label="Insert HTML elements">
  365. ` + themeList + `
  366. </ul>
  367. </div>`;
  368. var classesButton = `
  369. <div class="note-btn-group btn-group">
  370. <button type="button" class="btn btn-default btn-sm dropdown-toggle note-codeview-keep" tabindex="-1" data-toggle="dropdown" title="Custom CSS classes" aria-expanded="false">
  371. <i class="fa fa-css3"></i> <span class="note-icon-caret"></span>
  372. </button>
  373. <ul class="note-dropdown-menu dropdown-menu dropdown-classes scrollable-menu" aria-label="Toggle CSS classes">
  374. ` + classList + `
  375. </ul>
  376. </div>`;
  377. /* Example of a simple button for future reference
  378. var tooltipsButton = `
  379. <button type="button" id="tooltip-insert" class="btn btn-default btn-sm" tabindex="-1" title="Extraordinary Tooltips Helper" aria-label="Extraordinary Tooltips" data-original-title="Extraordinary Tooltips">
  380. <i class="fab fa-stack-exchange"></i>
  381. </button>
  382. `;*/
  383. buttons += snippetsButton + classesButton + `</div>`;
  384. toolbar.insertAdjacentHTML("beforeend", buttons);
  385.  
  386. /* ADD EVENTS FOR HTML INSERTER */
  387. // Grab our completed dropdown
  388. const snippetsDropdown = document.getElementsByClassName('dropdown-snippets')[0];
  389.  
  390. // Make sure we have at least one supported theme enabled
  391. if (snippetsDropdown.children[0]) {
  392. // Add click events to editor
  393. for (let i = 0; i < snippetsDropdown.children.length; i++) {
  394. snippetsButton = snippetsDropdown.children[i];
  395.  
  396. // Skip special helper entries
  397. if (snippetsButton.getAttribute("data-helper") == "true") {
  398. continue;
  399. }
  400.  
  401. snippetsButton.addEventListener('click', () => {
  402. // Code editor, not supported by Summernote functions so we're making our own
  403. if ($('#entry + div').hasClass('codeview')) {
  404. const codeEditor = $('#entry + div').find('.note-codable');
  405. var cursorPos = codeEditor.prop('selectionStart');
  406. var editorValue = codeEditor.val();
  407. var textBefore = editorValue.substring(0, cursorPos);
  408. var textAfter = editorValue.substring(cursorPos, editorValue.length);
  409. var newPos = cursorPos + snippetArray[i]["code"].length + 1;
  410. codeEditor.val(textBefore + '\n' + snippetArray[i]["code"] + textAfter);
  411.  
  412. // Return focus to textarea and select newly inserted string to make it clear to the user
  413. codeEditor[0].focus();
  414. codeEditor[0].setSelectionRange(cursorPos, newPos);
  415.  
  416. // Update Summernote’s hidden textarea in case of immediate saving
  417. $('#entry').val(codeEditor.val());
  418. }
  419. // Visual editor, API has us covered here
  420. else {
  421. var insertNode = $.parseHTML(snippetArray[i]["code"])[0];
  422. $('#entry').summernote('insertNode', insertNode);
  423. }
  424. });
  425. }
  426. }
  427. else {
  428. snippetsDropdown.insertAdjacentHTML("beforeend", "<li><a href='#'><em>No supported theme found</em></a></li>");
  429. }
  430.  
  431. /* ADD EVENTS FOR CLASS INSERTER */
  432. // Grab our completed dropdown
  433. const classesDropdown = $('#entry + div .dropdown-classes');
  434.  
  435. // Add click events to editor
  436. for (let i = 0; i < classesDropdown.find('li').length; i++) {
  437. classesButton = classesDropdown.find('li')[i];
  438.  
  439. classesButton.addEventListener('click', (e)=>{
  440. //e.preventDefault();
  441. // Code editor, not supported by Summernote functions so we're making our own?
  442. if ($('#entry + div').hasClass('codeview')) {
  443. // Grab selection from editor
  444. const codeEditor = document.getElementsByClassName('note-codable')[0];
  445. var selectedText = codeEditor.value.substring(codeEditor.selectionStart, codeEditor.selectionEnd);
  446. var initialStart = codeEditor.selectionStart;
  447. var initialEnd = codeEditor.selectionEnd;
  448. var initialLength = codeEditor.value.length;
  449.  
  450. // Make (reasonably) sure we are holding a single opening tag
  451. if (selectedText.slice(0, 1) == '<' && selectedText.slice(-1) == '>' && selectedText.slice(1, 2) !== '/' && selectedText.match(/</g).length == 1 && selectedText.match(/>/g).length == 1) {
  452. // Insert flag to locate this element further on
  453. var tagEnd = (selectedText.slice(-2) == '/>') ? '/>' : '>';
  454. var flaggedTag = selectedText.slice(0, -tagEnd.length) + ' data-class-inserter="target"' + tagEnd;
  455.  
  456. // Replace selection with modified tag
  457. codeEditor.focus();
  458. codeEditor.setRangeText(flaggedTag, codeEditor.selectionStart, codeEditor.selectionEnd, 'select');
  459.  
  460. // Create node from textarea
  461. var inputNode = $.parseHTML($('#entry + div').find('.note-codable').val());
  462.  
  463. // Toggle class on target node
  464. var targetNode = $(inputNode).closest('[data-class-inserter="target"]');
  465. if (targetNode.length == 0) { // The above only finds first-level elements, and the below only finds sub-elements. Go figure.
  466. targetNode = $(inputNode).find('[data-class-inserter="target"]');
  467. }
  468. if (targetNode.length == 0) { // Worst case scenario
  469. alert("Target element was lost along the way. Please report the issue and include the source HTML.");
  470. }
  471. $(targetNode).toggleClass(classArray[i]["name"]);
  472.  
  473. // Clean up and remove class attribute if no class remains after toggle
  474. if ($(targetNode).attr('class') == "") {
  475. $(targetNode).removeAttr('class');
  476. }
  477.  
  478. // Remove flag
  479. $(targetNode).removeAttr('data-class-inserter');
  480.  
  481. // Return to HTML (wrapper needed) and pass to textarea
  482. $('#entry + div').find('.note-codable').val($('<div></div>').append($(inputNode)).html());
  483.  
  484. // Apply changes to master copy in case of immediate save
  485. $('#entry').val($('#entry + div').find('.note-codable').val());
  486.  
  487. // Return focus to textarea and select newly inserted string to make it clear to the user
  488. var lengthDiff = $('#entry + div').find('.note-codable').val().length - initialLength;
  489. var newEnd = initialEnd + lengthDiff;/*
  490. console.log(initialStart);
  491. console.log(initialEnd);
  492. console.log(initialLength);
  493. console.log(lengthDiff);
  494. console.log(newEnd);*/
  495. codeEditor.setSelectionRange(initialStart, newEnd);
  496. }
  497. else {
  498. alert("To insert classes in Code View, you must first select the opening tag of the element (e.g. <table>).");
  499. }
  500.  
  501. }
  502. // Visual editor, API provides cursor position
  503. else {
  504. const range = $('#entry').summernote('editor.getLastRange');
  505. var targetNode = range.sc.parentNode;
  506.  
  507. // Any position outside the editor should be rejected
  508. if (!$('.note-editing-area').has(targetNode).length) {
  509. alert("Cursor position could not be found in the editor. Click the target element in the editor and try again. If the issue persists, your text may not be wrapped in any HTML element.");
  510. }
  511. else {
  512. // Add class
  513. $(targetNode).toggleClass(classArray[i]["name"]);
  514. // Apply changes to master copy in case of immediate save
  515. $('#entry').val($('#entry + div').find('.note-editable').html());
  516.  
  517. // Print status message
  518. var status = '<em class="fadeout">Class "' + classArray[i]["name"] +'"';
  519. status += ($(targetNode).hasClass(classArray[i]["name"])) ? ' added to ' : ' removed from ';
  520. status += '<span style="font-variant: all-petite-caps;">' + $(targetNode).prop('nodeName') + '</span> element.</em>';
  521. $('.note-status-output').html(status);
  522. // Remove message
  523. $('.note-status-output > .fadeout').fadeIn(500).delay(10000).fadeOut(400);
  524. }
  525. }
  526. });
  527. }
  528.  
  529. /* ADD EVENTS FOR TOOLTIP INSERTER */
  530. var tooltipsModal = `
  531. <div class="modal note-modal tooltip-dialog in" aria-hidden="false" tabindex="-1" role="dialog" aria-label="Insert Extraordinary Tooltip">
  532. <div class="modal-dialog">
  533. <div class="modal-content">
  534. <div class="modal-header">
  535. <button type="button" class="close float-right text-xl" data-dismiss="modal" aria-label="Close" aria-hidden="true">×</button>
  536. <h4 class="modal-title">Insert Extraordinary Tooltip</h4>
  537. <em><a href="https://salvatos.gitbook.io/kanka-cookbook/power-users/extraordinary-tooltips-user-guide" target="_blank">User guide (new tab)</a></em>
  538. </div>
  539. <div class="modal-body">
  540. <form id="tooltip-form">
  541. <div class="form-group note-form-group">
  542. <h5>Choose your trigger location:</h5>
  543. <input type="radio" id="tooltip-trigger-location1" name="tooltip-trigger-location" value="standalone" checked="">
  544. <label for="tooltip-trigger-location1" class="note-form-label">Standalone <em>(no other content on the same line)</em></label><br>
  545. <input type="radio" id="tooltip-trigger-location2" name="tooltip-trigger-location" value="inline">
  546. <label for="tooltip-trigger-location2" class="note-form-label">Inline <em>(shares a line with other content)</em></label>
  547. </div>
  548. <div class="form-group note-form-group">
  549. <h5>Should an icon be attached to the trigger?</h5>
  550. <input type="radio" id="tooltip-trigger-icon1" name="tooltip-trigger-icon" value="icon" checked="">
  551. <label for="tooltip-trigger-icon1" class="note-form-label">Show icon</label><br>
  552. <input type="radio" id="tooltip-trigger-icon2" name="tooltip-trigger-icon" value="no-icon">
  553. <label for="tooltip-trigger-icon2" class="note-form-label">No icon</label>
  554. </div>
  555. <div class="form-group note-form-group">
  556. <h5>Should the tooltip be fixed or affixed?</h5>
  557. <input type="radio" id="tooltip-mode1" name="tooltip-mode" value="fixed" checked="">
  558. <label for="tooltip-mode1" class="note-form-label">Fixed <em>(middle of screen)</em></label><br>
  559. <input type="radio" id="tooltip-mode2" name="tooltip-mode" value="affixed">
  560. <label for="tooltip-mode2" class="note-form-label">Affixed <em>(immediately under trigger)</em></label>
  561. </div>
  562. <div class="form-group note-form-group">
  563. <h5>What is your content source?</h5>
  564. <input type="radio" id="tooltip-source1" name="tooltip-source" value="entry" checked="">
  565. <label for="tooltip-source1" class="note-form-label">Entry <em>(entity mention)</em></label><br>
  566. <input type="radio" id="tooltip-source2" name="tooltip-source" value="attribute">
  567. <label for="tooltip-source2" class="note-form-label">Attribute <em>(attribute mention)</em></label>
  568. </div>
  569. <div class="form-group note-form-group">
  570. <label for="tooltip-wrapper-classes" class="note-form-label">Insert any additional classes for the tooltip wrapper below:</label>
  571. <input id="tooltip-wrapper-classes" class="form-control note-form-control note-input" type="text" value="">
  572. </div>
  573. </form>
  574. <div id="tooltip-demo">
  575. <h4>Live example:</h4>
  576. <output class="entity-content" contenteditable="true"></output>
  577. <em style="font-size: 13px;">You can type in this box to replace any of the text, even in the tooltip! Note that your changes will be discarded if you update any option in the form. Custom styling may differ due to modal context.</em>
  578. </div>
  579. </div>
  580. <div class="modal-footer">
  581. <h5>Please copy this code and paste it in the <u>code</u> editor: <input id="copycode" type="button" href="#_" class="btn2 btn-primary" value="Copy code"></h5>
  582. <output id="tooltip-output" name="tooltip-output" for="tooltip-form"><pre></pre></output>
  583. </div>
  584. </div>
  585. </div>
  586. </div>
  587. `;
  588. document.getElementsByClassName("note-editor")[0].insertAdjacentHTML("beforeend", tooltipsModal);
  589. // On Helper list item click, open modal, generate default code and force code view
  590. $("#ExT-Helper").on("click", function() {
  591. $(".tooltip-dialog").toggle();
  592. parseTooltips();
  593. // Do not activate code view if it’s already active, or changes will be discarded!
  594. if ($('#entry + div .note-codable').css('display') == "none") {
  595. $('#entry').summernote('codeview.activate');
  596. }
  597. });
  598.  
  599. // On x click, close modal
  600. $(".tooltip-dialog .close").on("click", function() { $(".tooltip-dialog").toggle(); });
  601. // On input, update code
  602. $("#tooltip-form").on("input", parseTooltips);
  603. // Copy code to clipboard (no IE support)
  604. $("#copycode").on("click", function() {
  605. navigator.clipboard.writeText($("#tooltip-output > pre").text()).then(function() {
  606. alert("Code added to clipboard");
  607. }, function() {
  608. alert("Failed to save to clipboard");
  609. });
  610. });
  611.  
  612. function parseTooltips() {
  613. // Hide icon?
  614. let iconString = ($('input[name="tooltip-trigger-icon"]:checked').val() == "no-icon") ? " no-icon" : "";
  615. // Affixed or fixed (default)?
  616. let modeString = ($('input[name="tooltip-mode"]:checked').val() == "affixed") ? "affixed" : "fixed";
  617. // Add custom classes?
  618. let wrapperClasses = $('input#tooltip-wrapper-classes').val();
  619.  
  620. let tooltipString = ``;
  621. // Inline open?
  622. if ($('input[name="tooltip-trigger-location"]:checked').val() == "inline") {
  623. tooltipString += `<p class="ExT-inline">Text</p>\n`;
  624. }
  625. // Trigger
  626. tooltipString += `<span class="ExT-trigger` + iconString + `">Trigger</span>\n`;
  627. // Attribute or entry (default)?
  628. if ($('input[name="tooltip-source"]:checked').val() == "attribute") {
  629. tooltipString += `<div class="ExT-wrapper ExT-attribute ` + modeString + ` ` + wrapperClasses + `">{attribute}</div>`;
  630. }
  631. else {
  632. tooltipString += `<div class="ExT-wrapper ExT-entry ` + modeString + ` ` + wrapperClasses + `">\n<span class="ExT-wrap">\n[entity|field:entry]\n</span>\n</div>`;
  633. }
  634. // Inline close?
  635. if ($('input[name="tooltip-trigger-location"]:checked').val() == "inline") {
  636. tooltipString += `\n<p class="ExT-inline">Text</p>`;
  637. }
  638.  
  639. // Show output
  640. $("#tooltip-output > pre").text(tooltipString);
  641. $("#tooltip-demo output").html(tooltipString);
  642. }
  643.  
  644. /* ADD EVENTS FOR EASYTABS INSERTER */
  645. var easytabsModal = `
  646. <div class="modal note-modal easytabs-dialog in" aria-hidden="false" tabindex="-1" role="dialog" aria-label="Insert Easy Tabs">
  647. <div class="modal-dialog">
  648. <div class="modal-content">
  649. <div class="modal-header">
  650. <button type="button" class="close float-right text-xl" data-dismiss="modal" aria-label="Close" aria-hidden="true">×</button>
  651. <h4 class="modal-title">Insert Easy Tabs</h4>
  652. </div>
  653. <div class="modal-body">
  654. <form id="easytabs-form">
  655. <div class="form-group note-form-group">
  656. <p>Your current tabs appear below, and you can edit their titles directly. Drag-and-drop their arrow icon to reorder them. The tabs contents must be edited in Summernote.</p>
  657. <output id="easytabs-items" style="padding-bottom: 10px;"></output>
  658. <h5>Add or remove tabs:</h5>
  659. <div style="display: flex; gap: 0 10px; margin-top: 15px; align-items: center;">
  660. <select id="easytabs-remove" class="easytabs-select"></select>
  661. <input id="easytabs-delete" type="button" href="#_" class="btn2 btn-error" value="Delete selected">
  662. <input id="easytabs-add" type="button" href="#_" class="btn2 btn-primary" value="Add new tab">
  663. </div>
  664. </div>
  665. </div>
  666. <div class="modal-footer">
  667. <h5>Default tab:</h5>
  668. <select id="easytabs-default" class="easytabs-select"></select>
  669. <input id="easytabs-save" type="button" href="#_" class="btn2 btn-primary" value="Save and close">
  670. </div>
  671. </div>
  672. </div>
  673. </div>
  674. `;
  675. document.getElementsByClassName("note-editor")[0].insertAdjacentHTML("beforeend", easytabsModal);
  676.  
  677. // On Helper list item click, open modal if in visual editor only
  678. $("#Easytabs-Helper").on("click", function() {
  679. if ($('#entry + div .note-codable').css('display') == "none") {
  680. $(".easytabs-dialog").toggle();
  681. grabEasytabs();
  682. }
  683. else {
  684. // Print error message in Summernote
  685. var status = "<em class='fadeout'>The Easy Tabs Helper is only available in Visual mode. Please close Code view and try again.</em>";
  686. $('.note-status-output').html(status);
  687. // Remove message
  688. $('.note-status-output > .fadeout').fadeIn(500).delay(10000).fadeOut(400);
  689. }
  690. });
  691.  
  692. // Add tab
  693. $("#easytabs-add").on("click", function() {
  694. // Make a timestamp-based unique ID
  695. let newTabTime = "" + Date.now();
  696. newTabTime = newTabTime.substring(2);
  697.  
  698. // Insert tab toggle at the end
  699. document.querySelector("#easytabs-items .easytabs-toggles").insertAdjacentHTML("beforeend", `<div class="easytabs-dragger">
  700. <div class="fa-solid fa-arrows-alt-h drag-handle easytabs-dragger"></div>
  701. <a href="#easytab-`+newTabTime+`" class="easytabs-tab" contenteditable>New Tab `+newTabTime+`</a>
  702. </div>`);
  703. // Add listener to update names in selects on input
  704. $("#easytabs-items .easytabs-toggles > div.easytabs-dragger:last-child .easytabs-tab").on("input", function() { updateEasytabNames(this.innerHTML, this.hash) });
  705.  
  706. // Insert tab container at the start (to avoid replacing the default container, which has to be last in DOM)
  707. document.querySelector("#easytabs-items .easytabs-toggles").insertAdjacentHTML("afterend", `<div id="easytab-`+newTabTime+`" class="easytabs-content" data-new-easytab="new"></div>`);
  708.  
  709. // Add tab to selects
  710. document.getElementById("easytabs-default").innerHTML += `<option value="#easytab-${newTabTime}">New Tab ${newTabTime}</option>`;
  711. document.getElementById("easytabs-remove").innerHTML += `<option value="#easytab-${newTabTime}">New Tab ${newTabTime}</option>`;
  712. });
  713.  
  714. // Delete tab
  715. $("#easytabs-delete").on("click", function() {
  716. // Find the tab's id and destroy it and its content
  717. let removeTab = document.getElementById("easytabs-remove").value;
  718. document.querySelector("#easytabs-items .easytabs-tab[href='"+removeTab+"']").parentElement.remove();
  719. document.querySelector("#easytabs-items .easytabs-content"+removeTab).remove();
  720. // Also remove it from the selects
  721. document.querySelector("#easytabs-default option[value='"+removeTab+"']").remove();
  722. document.querySelector("#easytabs-remove option[value='"+removeTab+"']").remove();
  723. });
  724.  
  725. // Close modal and discard changes on X
  726. $(".easytabs-dialog .close").on("click", closeEasytabsHelper);
  727.  
  728. // Save and close
  729. $("#easytabs-save").on("click", function() {
  730. // Grab the content div that matches the default tab’s id and move it to the end of the group
  731. document.querySelector("#easytabs-items .easytabs").appendChild(document.querySelector("#easytabs-items .easytabs-content"+document.getElementById("easytabs-default").value));
  732.  
  733. // If a tab’s content is empty, it hasn’t been changed by the user yet so we add a placeholder to facilitate editing
  734. let tabContents = document.querySelectorAll("#easytabs-items .easytabs-content[data-new-easytab='new']"); // Look only at those created in the current session
  735. tabContents.forEach((child , index) => {
  736. if (child.innerHTML == "") { // But make sure the user didn’t close the modal, edit stuff, then reopen the modal
  737. child.innerHTML = "<p>Contents of tab " + document.querySelector("#easytabs-items a[href='#" + child.id + "']").innerHTML + "</p>";
  738. }
  739. });
  740.  
  741. // Remove all draggers and the sortable class for lighter output
  742. $(".easytabs-dragger a").detach().appendTo("#easytabs-items .easytabs-toggles"); // take the anchors out first
  743. $(".easytabs-dragger").remove();
  744. document.querySelector("#easytabs-items .easytabs-toggles").classList.remove("sortable-elements");
  745.  
  746. // Remove existing tab group if any, then insert the new/updated one
  747. if (document.cloneSource) {
  748. document.cloneSource.remove();
  749. }
  750. $('#entry').summernote('insertNode', document.querySelector("#easytabs-items > .easytabs"));
  751. $('#entry').summernote('pasteHTML', "<p></p>"); // Add an empty paragraph after for easier escaping if there is no other content
  752.  
  753. // Clean and close modal
  754. closeEasytabsHelper();
  755. });
  756.  
  757. function grabEasytabs() {
  758. const range = $('#entry').summernote('editor.getLastRange');
  759. var targetNode = range.sc.parentNode;
  760.  
  761. if (targetNode.classList.contains("easytabs")) { // Pointer on wrapper
  762. document.cloneSource = targetNode;
  763. }
  764. else if ($(targetNode).parents(".easytabs")[0]) { // Pointer inside wrapper
  765. document.cloneSource = $(targetNode).parents(".easytabs")[0];
  766. }
  767. // If editing a tab group, clear the output from previous uses of the modal and insert match
  768. if (document.cloneSource) {
  769. document.getElementById("easytabs-items").innerHTML = "";
  770. $(document.cloneSource).clone().appendTo("#easytabs-items");
  771. // wrap tabs in sortable draggers
  772. $( "#easytabs-items .easytabs-tab" ).wrap( "<div class='easytabs-dragger'></div>" );
  773.  
  774. // Populate tab selectors (todo: would it be more optimized to make one then clone it? feels cleaner at any rate)
  775. let currentTabs = document.querySelectorAll("#easytabs-items .easytabs-tab");
  776. let tabSelect1 = document.getElementById("easytabs-default");
  777. let tabSelect2 = document.getElementById("easytabs-remove");
  778. currentTabs.forEach((child , index) => {
  779. tabSelect1.innerHTML += `<option value="${child.hash}">${child.innerHTML}</option>`;
  780. tabSelect2.innerHTML += `<option value="${child.hash}">${child.innerHTML}</option>`;
  781.  
  782. // also give tabs handles for reordering and make them editable
  783. child.insertAdjacentHTML("beforebegin", `<div class="fa-solid fa-arrows-alt-h drag-handle easytabs-dragger"></div>`);
  784. child.contentEditable = true;
  785. }, tabSelect1, tabSelect2);
  786.  
  787. // Listener: update names in selects on input
  788. $("#easytabs-items .easytabs-tab").on("input", function() { updateEasytabNames(this.innerHTML, this.hash) });
  789. }
  790. // Else, clear output and insert a new, empty tab container
  791. else {
  792. document.getElementById("easytabs-items").innerHTML = "<p><em>No Easy Tabs found at your cursor position. Click <strong>Add new tab</strong> below to create a new tab group, or close this modal and place your cursor in an existing one to edit it.</em></p>";
  793. document.getElementById("easytabs-items").insertAdjacentHTML("beforeend", `<div class="easytabs"><div class="easytabs-toggles"></div></div>`);
  794. }
  795. // Make tabs sortable
  796. document.querySelector("#easytabs-items .easytabs-toggles").classList.add("sortable-elements");
  797. document.querySelector("#easytabs-items .easytabs-toggles").setAttribute("data-handle", ".drag-handle");
  798. initSortable();
  799. }
  800.  
  801. function updateEasytabNames(newName, uid) {
  802. // console.log("Looking for " + uid + " to change to " + newName);
  803. document.querySelector("#easytabs-default option[value='"+uid+"']").innerHTML = newName;
  804. document.querySelector("#easytabs-remove option[value='"+uid+"']").innerHTML = newName;
  805. }
  806.  
  807. function closeEasytabsHelper() {
  808. $(".easytabs-dialog").toggle();
  809. // Clear cloneSource and tab selects for future uses of the modal
  810. document.cloneSource = null;
  811. document.getElementById("easytabs-default").innerHTML = "";
  812. document.getElementById("easytabs-remove").innerHTML = "";
  813. }
  814.  
  815.  
  816. // EXTRAS
  817. // Add a listener to each spoiler tag to save and apply its open/closed state
  818. // (otherwise they are only updated if other changes are made in the editor before saving)
  819. function addSpoilerListeners() {
  820. let spoilers = document.querySelectorAll('.html-editor + .note-editor details');
  821. spoilers.forEach((spoiler) => {
  822. spoiler.addEventListener('toggle', function() {
  823. $('.html-editor').val($('.html-editor + .note-editor .note-editable').html());
  824. });
  825. });
  826. }
  827.  
  828. // Add listener to each existing spoiler tag
  829. addSpoilerListeners();
  830.  
  831. // Add listener to new spoiler tags as they are created (wait 1 sec to make sure they are inserted before this runs)
  832. setTimeout(function() { $('.html-editor + .note-editor li[aria-label^="Spoiler block"]').on('click', addSpoilerListeners); }, 1000);
  833. });