From ad5b502012e4bf06865a0631c2c7f1567bbd632d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C4=82LTEANU?= Date: Fri, 2 Jan 2026 15:50:29 +0700 Subject: [PATCH] wip --- app/assets/images/icons.svg | 91 ++++ app/assets/stylesheets/jstoolbar.css | 2 +- app/assets/stylesheets/wiki_editor.css | 174 ++++++++ app/helpers/application_helper.rb | 4 +- app/helpers/browser_helper.rb | 11 + app/helpers/wiki_toolbar_helper.rb | 77 ++++ .../common_mark_toolbar_controller.js | 96 +++++ .../controllers/textile_toolbar_controller.js | 88 ++++ .../controllers/wiki_toolbar_controller.js | 391 ++++++++++++++++++ app/views/issues/_edit.html.erb | 5 +- app/views/issues/_form.html.erb | 8 +- app/views/issues/_form_custom_fields.html.erb | 2 +- app/views/layouts/base.html.erb | 2 +- config/locales/en.yml | 19 + .../wiki_formatting/common_mark/helper.rb | 32 ++ lib/redmine/wiki_formatting/textile/helper.rb | 31 ++ .../views/labelled_form_builder_test.rb | 10 + 17 files changed, 1033 insertions(+), 10 deletions(-) create mode 100644 app/assets/stylesheets/wiki_editor.css create mode 100644 app/helpers/browser_helper.rb create mode 100644 app/helpers/wiki_toolbar_helper.rb create mode 100644 app/javascript/controllers/common_mark_toolbar_controller.js create mode 100644 app/javascript/controllers/textile_toolbar_controller.js create mode 100644 app/javascript/controllers/wiki_toolbar_controller.js diff --git a/app/assets/images/icons.svg b/app/assets/images/icons.svg index b209a135e..18d765b51 100644 --- a/app/assets/images/icons.svg +++ b/app/assets/images/icons.svg @@ -82,6 +82,10 @@ + + + + @@ -235,6 +239,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -258,6 +293,21 @@ + + + + + + + + + + + + + + + @@ -278,6 +328,11 @@ + + + + + @@ -301,6 +356,21 @@ + + + + + + + + + + + + + + + @@ -434,9 +504,20 @@ + + + + + + + + + + + @@ -534,6 +615,10 @@ + + + + @@ -562,6 +647,12 @@ + + + + + + diff --git a/app/assets/stylesheets/jstoolbar.css b/app/assets/stylesheets/jstoolbar.css index 2d98864ad..0a4eba303 100644 --- a/app/assets/stylesheets/jstoolbar.css +++ b/app/assets/stylesheets/jstoolbar.css @@ -144,7 +144,7 @@ .jstb_unbq { background-image: url(/jstoolbar/indent-decrease.svg); } -.jstb_pre::before { +.jstBlock .jstb_pre::before { content: "pre"; font-size: 10px; color: var(--oc-gray-9); diff --git a/app/assets/stylesheets/wiki_editor.css b/app/assets/stylesheets/wiki_editor.css new file mode 100644 index 000000000..3f60aa9e7 --- /dev/null +++ b/app/assets/stylesheets/wiki_editor.css @@ -0,0 +1,174 @@ +.wiki-editor { + border: 1px solid #d7d7d7; + border-radius: 3px; + margin-bottom: 10px; + background: #fff; +} + +.wiki-editor:has(.wiki-editor-edit:not(.hidden)):focus-within { + border-color: var(--oc-blue-5); +} + +.wiki-editor-header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + /* Align tabs to bottom */ + justify-content: flex-start; + padding-left: 0.5em; + /* Remove bottom padding */ + background-color: #f6f6f6; + border-bottom: 1px solid #d7d7d7; + gap: 10px; +} + +.wiki-editor-tabs { + display: flex; + align-items: stretch; + gap: 4px; + /* No gap between tabs */ +} + +.wiki-editor-tab { + border: 1px solid transparent; + height: auto; + border-bottom: none; + /* No bottom border to merge with header border */ + background: none; + padding: 6px 12px; + border-radius: 3px 3px 0 0; + cursor: pointer; + color: #555; + font-size: 0.9em; + font-weight: 500; + margin-bottom: -1px; + margin-top: -1px; +} + +.wiki-editor-tab:hover { + background-color: rgba(0, 0, 0, 0.05); + color: #333; +} + +.wiki-editor-tab.selected { + background-color: #fff; + border-color: #d7d7d7; + color: #222; + font-weight: 600; + z-index: 2; + /* Bring above header border */ +} + +.wiki-editor-buttons { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + padding-bottom: 0; +} + +.wiki-editor-buttons.hidden { + display: none; +} + +.wiki-editor-buttons button { + background: none; + border: 1px solid transparent; + border-radius: 3px; + padding: 2px; + cursor: pointer; + min-width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: #555; +} + +.wiki-editor-buttons button:hover { + background-color: #e6e6e6; + border-color: #d0d0d0; + color: #111; +} + +.wiki-editor-buttons button svg { + stroke-width: 2; +} + +/* Icons are usually background images or fonts in Redmine. + Assuming render_wikitoolbar_buttons outputs buttons with classes that have background icons. + Existing jstoolbar.css usually handles .jstb_strong etc. + We might need to ensure those classes still work or adapt them. + For now, I'm setting structural CSS. +*/ + +.wiki-editor-pane { + padding: 0; +} + +.wiki-editor-pane textarea { + width: 100%; + border: none; + padding: 10px; + box-sizing: border-box; + min-height: 200px; + resize: vertical; + display: block; + outline: none; + /* Let the container border handle focus focus indicator potentially, or keep default */ +} + +/* Adjustments for Redmine default styles overriding */ +/* .wiki-editor-pane textarea:focus { + box-shadow: none; + outline: none; +} */ + +.wiki-editor-preview { + padding: 10px; + min-height: 200px; + background-color: #fff; +} + +.wiki-editor-preview>p:first-child { + padding-top: 0 !important; + margin-top: 0 !important; +} + +.wiki-editor-preview>p:last-child { + padding-bottom: 0 !important; + margin-bottom: 0 !important; +} + +/* Container alignment fix for tabular forms */ +.tabular .wiki-edit-container { + margin: 0; + padding: 3px 0 3px 0; + padding-left: 180px; + min-height: 2em; + clear: left; + overflow: hidden; +} + +/* Table Generator Picker */ +.table-generator { + position: absolute; + border-collapse: collapse; + z-index: 1000; + background-color: #fff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #d7d7d7; +} + +.table-generator td { + border: 1px solid #eee; + padding: 10px; + cursor: pointer; + width: 10px; + height: 10px; +} + +.table-generator td.selected-cell { + background-color: var(--oc-blue-1); + border-color: var(--oc-blue-3); +} \ No newline at end of file diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5754855cc..dd3a82c89 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -29,9 +29,11 @@ module ApplicationHelper include Redmine::Hook::Helper include Redmine::Helpers::URL include IconsHelper + include WikiToolbarHelper + include BrowserHelper extend Forwardable - def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter + def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter, :wikitoolbar_buttons, :wikitoolbar_controller_name # Return true if user is authorized for controller/action, otherwise false def authorize_for(controller, action) diff --git a/app/helpers/browser_helper.rb b/app/helpers/browser_helper.rb new file mode 100644 index 000000000..45d0a1699 --- /dev/null +++ b/app/helpers/browser_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module BrowserHelper + def mac_os? + request.user_agent.downcase.include?('macintosh') + end + + def modifier_key + mac_os? ? '⌘' : 'Ctrl' + end +end diff --git a/app/helpers/wiki_toolbar_helper.rb b/app/helpers/wiki_toolbar_helper.rb new file mode 100644 index 000000000..6afdd9ada --- /dev/null +++ b/app/helpers/wiki_toolbar_helper.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module WikiToolbarHelper + def wikitoolbar(field_id, preview_url = preview_text_path, &block) + controller_name = wikitoolbar_controller_name + + content_tag(:div, + class: 'wiki-editor', + data: { + controller: controller_name, + field_id: field_id, + "#{controller_name}-preview-url-value": preview_url, + "#{controller_name}-help-url-value": help_wiki_syntax_path, + "#{controller_name}-languages-value": (User.current && User.current.pref.toolbar_language_options || UserPreference::DEFAULT_TOOLBAR_LANGUAGE_OPTIONS).split(',') + }) do + concat( + content_tag(:div, class: 'wiki-editor-header') do + concat( + content_tag(:div, class: 'wiki-editor-tabs') do + concat button_tag(l(:button_edit), type: 'button', class: 'wiki-editor-tab selected', data: { action: "#{controller_name}#edit", "#{controller_name}-target": "editTab" }) + concat button_tag(l(:label_preview), type: 'button', class: 'wiki-editor-tab', data: { action: "#{controller_name}#preview", "#{controller_name}-target": "previewTab" }) + end + ) + concat( + content_tag(:div, class: 'wiki-editor-buttons', data: { "#{controller_name}-target": "buttons" }) do + render_wikitoolbar_buttons + end + ) + end + ) + + concat( + content_tag(:div, class: 'wiki-editor-pane wiki-editor-edit', data: { "#{controller_name}-target": "editPane" }, &block) + ) + + concat( + content_tag(:div, '', class: 'wiki-editor-pane wiki-editor-preview wiki hidden', data: { "#{controller_name}-target": "previewPane" }) + ) + end + end + + def render_wikitoolbar_buttons + buttons = wikitoolbar_buttons + controller_name = wikitoolbar_controller_name + + buttons.map do |button| + if button[:class] == 'jstSpacer' || button[:class] == 'spacer' + content_tag('span', '', class: 'jstSpacer') + else + title = l(button[:label]) + title += " (#{modifier_key}+#{button[:shortcut].upcase})" if button[:shortcut] + + tag.button( + wikitoolbar_button_content(button, title), + type: 'button', + class: button[:class], + title: title, + data: { action: "#{controller_name}##{button[:action]}" } + ) + end + end.join.html_safe + end + + private + + def wikitoolbar_button_content(button, title) + if button[:icon] == 'pre' + content_tag(:svg, class: 's16 icon-svg', aria: { hidden: true }, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', +'stroke-linejoin': 'round') do + concat tag.rect(x: '3', y: '5', width: '18', height: '14', rx: '2') + concat tag.text('pre', x: '12', y: '15', 'text-anchor': 'middle', style: 'font-size: 8px; font-weight: bold; font-family: monospace;', fill: 'currentColor', stroke: 'none') + end + else + sprite_icon(button[:icon], title, icon_only: true, size: 16) + end + end +end diff --git a/app/javascript/controllers/common_mark_toolbar_controller.js b/app/javascript/controllers/common_mark_toolbar_controller.js new file mode 100644 index 000000000..645a4bd99 --- /dev/null +++ b/app/javascript/controllers/common_mark_toolbar_controller.js @@ -0,0 +1,96 @@ +import WikiToolbarController from "controllers/wiki_toolbar_controller" + +export default class extends WikiToolbarController { + strong() { + this.singleTag('**') + } + + italic() { + this.singleTag('*') + } + + underline() { + this.singleTag('', '') + } + + deleted() { + this.singleTag('~~') + } + + inlineCode() { + this.singleTag('`') + } + + h1() { + this.encloseLineSelection('# ', '', (str) => { + return str.replace(/^#+\s+/, '') + }) + } + + h2() { + this.encloseLineSelection('## ', '', (str) => { + return str.replace(/^#+\s+/, '') + }) + } + + h3() { + this.encloseLineSelection('### ', '', (str) => { + return str.replace(/^#+\s+/, '') + }) + } + + unorderedList() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)[#-]?\s*/g, "$1* ") + }) + } + + orderedList() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)[*-]?\s*/g, "$11. ") + }) + } + + taskList() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)[*-]?\s*/g, "$1* [ ] ") + }) + } + + quote() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)( *)([^\n]*)/g, "$1> $2$3") + }) + } + + unquote() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^) *(> ?)?( *)([^\n]*)/g, "$1$3$4") + }) + } + + pre() { + this.encloseLineSelection('```\n', '\n```') + } + + image() { + this.encloseSelection("![](", ")") + } + + insertTable(cols, rows) { + const alphabets = "ABCDEFGHIJ".split('') + const header = '|' + alphabets.slice(0, cols).join(' |') + ' |\n' + const separator = Array(cols + 1).join('|--') + '|\n' + const cells = Array(rows + 1).join(Array(cols + 1).join('| ') + '|\n') + this.encloseLineSelection(header + separator + cells, '') + } + + insertPrecode(lang) { + this.encloseLineSelection('``` ' + lang + '\n', '\n```\n') + } +} diff --git a/app/javascript/controllers/textile_toolbar_controller.js b/app/javascript/controllers/textile_toolbar_controller.js new file mode 100644 index 000000000..9d02a2e29 --- /dev/null +++ b/app/javascript/controllers/textile_toolbar_controller.js @@ -0,0 +1,88 @@ +import WikiToolbarController from "controllers/wiki_toolbar_controller" + +export default class extends WikiToolbarController { + strong() { + this.singleTag('*') + } + + italic() { + this.singleTag('_') + } + + underline() { + this.singleTag('+') + } + + deleted() { + this.singleTag('-') + } + + inlineCode() { + this.singleTag('@') + } + + h1() { + this.encloseLineSelection('h1. ', '', (str) => { + return str.replace(/^h\d+\.\s+/, '') + }) + } + + h2() { + this.encloseLineSelection('h2. ', '', (str) => { + return str.replace(/^h\d+\.\s+/, '') + }) + } + + h3() { + this.encloseLineSelection('h3. ', '', (str) => { + return str.replace(/^h\d+\.\s+/, '') + }) + } + + unorderedList() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)[#-]?\s*/g, "$1* ") + }) + } + + orderedList() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)[*-]?\s*/g, "$1# ") + }) + } + + quote() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^)( *)([^\n]*)/g, "$1> $2$3") + }) + } + + unquote() { + this.encloseLineSelection('', '', (str) => { + str = str.replace(/\r/g, '') + return str.replace(/(\n|^) *(> ?)?( *)([^\n]*)/g, "$1$3$4") + }) + } + + pre() { + this.encloseLineSelection('
\n', '\n
') + } + + image() { + this.encloseSelection("!", "!") + } + + insertTable(cols, rows) { + const alphabets = "ABCDEFGHIJ".split('') + const header = '|_.' + alphabets.slice(0, cols).join('|_.') + '|\n' + const cells = Array(rows + 1).join(Array(cols + 1).join('| ') + '|\n') + this.encloseLineSelection(header + cells, '') + } + + insertPrecode(lang) { + this.encloseLineSelection('
\n', '\n
\n') + } +} diff --git a/app/javascript/controllers/wiki_toolbar_controller.js b/app/javascript/controllers/wiki_toolbar_controller.js new file mode 100644 index 000000000..54ade3a81 --- /dev/null +++ b/app/javascript/controllers/wiki_toolbar_controller.js @@ -0,0 +1,391 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + + static targets = ["field", "previewPane", "editPane", "editTab", "previewTab", "buttons"] + static values = { + languages: Array, + previewUrl: String, + helpUrl: String + } + + connect() { + this.field = this.hasFieldTarget ? this.fieldTarget : document.getElementById(this.element.dataset.fieldId) + if (!this.field) return + + this.setupShortcuts() + } + + strong() { + // abstract + } + + italic() { + // abstract + } + + underline() { + // abstract + } + + deleted() { + // abstract + } + + inlineCode() { + // abstract + } + + h1() { + // abstract + } + + h2() { + // abstract + } + + h3() { + // abstract + } + + unorderedList() { + // abstract + } + + orderedList() { + // abstract + } + + quote() { + // abstract + } + + unquote() { + // abstract + } + + pre() { + // abstract + } + + help() { + window.open(this.helpUrlValue, "_blank") + } + + precode(event) { + event.preventDefault() + this.precodeMenu(event.currentTarget) + } + + table(event) { + event.preventDefault() + this.tableMenu(event.currentTarget) + } + + wikiLink() { + this.encloseSelection("[[", "]]") + } + + image() { + // abstract + } + + // Common text manipulation methods + singleTag(startTag, endTag) { + endTag = endTag || startTag + this.encloseSelection(startTag, endTag) + } + + encloseSelection(prefix, suffix, fn) { + this.field.focus() + prefix = prefix || '' + suffix = suffix || '' + + let start = this.field.selectionStart + let end = this.field.selectionEnd + let scrollPos = this.field.scrollTop + let sel = this.field.value.substring(start, end) + let subst + + if (start > 0 && this.field.value.charAt(start - 1).match(/\S/)) { + prefix = ' ' + prefix + } + if (this.field.value.charAt(end).match(/\S/)) { + suffix = suffix + ' ' + } + + if (sel.match(/ $/)) { + sel = sel.substring(0, sel.length - 1) + suffix = suffix + " " + } + + let res = (typeof fn === 'function') ? ((sel) ? fn.call(this, sel) : fn('')) : (sel ? sel : '') + subst = prefix + res + suffix + + this.field.setRangeText(subst, start, end, 'select') + this.field.selectionStart = start + prefix.length + this.field.selectionEnd = start + prefix.length + res.length + this.field.scrollTop = scrollPos + } + + encloseLineSelection(prefix, suffix, fn) { + this.field.focus() + prefix = prefix || '' + suffix = suffix || '' + + let start = this.field.selectionStart + let end = this.field.selectionEnd + let scrollPos = this.field.scrollTop + + // Go to start of line + while (start > 0 && this.field.value.charAt(start - 1) !== '\n' && this.field.value.charAt(start - 1) !== '\r') { + start-- + } + + // Go to end of line + while (end < this.field.value.length && this.field.value.charAt(end) !== '\n' && this.field.value.charAt(end) !== '\r') { + end++ + } + + let sel = this.field.value.substring(start, end) + if (sel.match(/ $/)) { + sel = sel.substring(0, sel.length - 1) + suffix = suffix + " " + } + + let res = (typeof fn === 'function') ? ((sel) ? fn.call(this, sel) : fn('')) : (sel ? sel : '') + let subst = prefix + res + suffix + + this.field.setRangeText(subst, start, end, 'select') + this.field.selectionStart = start + prefix.length + this.field.selectionEnd = start + prefix.length + res.length + this.field.scrollTop = scrollPos + } + + setupShortcuts() { + this.field.addEventListener('keydown', (e) => { + if (this.isModifierKey(e)) { + const key = e.key.toLowerCase() + const shortcuts = { + 'b': 'strong', + 'i': 'italic', + 'u': 'underline' + } + if (shortcuts[key]) { + e.preventDefault() + this[shortcuts[key]]() + } + } + }) + } + + isModifierKey(e) { + const isMac = navigator.platform.toLowerCase().indexOf('mac') > -1 + return isMac ? e.metaKey : e.ctrlKey + } + + async preview(event) { + event.preventDefault() + const previewTab = event.currentTarget + if (previewTab.classList.contains('selected')) return + + const formData = new FormData() + formData.append('text', this.field.value) + const form = this.field.closest('form') + const attachmentInputs = form.querySelectorAll('.attachments_fields input'); + + attachmentInputs.forEach(input => { + if (input.name && input.value) { + formData.append(input.name, input.value); + } + }); + + // Add authenticity token + const token = document.querySelector('meta[name="csrf-token"]') + if (token) { + formData.append('authenticity_token', token.content) + } + + try { + const response = await fetch(this.previewUrlValue, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + + if (response.ok) { + const html = await response.text() + this.showPreview(html) + } + } catch (error) { + console.error("Preview failed", error) + } + } + + edit(event) { + event.preventDefault() + const editTab = event.currentTarget + if (editTab.classList.contains('selected')) return + + this.hidePreview(editTab) + } + + showPreview(html) { + // Find or create preview div + this.previewPaneTarget.innerHTML = html + this.editPaneTarget.classList.add('hidden') + this.previewPaneTarget.classList.remove('hidden') + this.editTabTarget.classList.remove('selected') + this.previewTabTarget.classList.add('selected') + this.buttonsTarget.classList.add('hidden') + } + + hidePreview(editTab) { + this.editPaneTarget.classList.remove('hidden') + this.previewPaneTarget.classList.add('hidden') + this.editTabTarget.classList.add('selected') + this.previewTabTarget.classList.remove('selected') + this.buttonsTarget.classList.remove('hidden') + } + + tableMenu(button) { + if (this.menu) { + this.menu.remove() + this.menu = null + return + } + + const alphabets = "ABCDEFGHIJ".split('') + const menu = document.createElement('table') + menu.className = 'table-generator' + this.menu = menu + + for (let r = 1; r <= 5; r++) { + const row = document.createElement('tr') + for (let c = 1; c <= 10; c++) { + const cell = document.createElement('td') + cell.dataset.row = r + cell.dataset.col = c + cell.title = `${c}×${r}` + + cell.addEventListener('mousedown', (e) => { + e.preventDefault() + this.insertTable(c, r) + this.menu.remove() + this.menu = null + }) + + cell.addEventListener('mouseenter', () => { + const cells = menu.querySelectorAll('td') + cells.forEach(el => { + if (parseInt(el.dataset.row) <= r && parseInt(el.dataset.col) <= c) { + el.classList.add('selected-cell') + } else { + el.classList.remove('selected-cell') + } + }) + }) + + row.appendChild(cell) + } + menu.appendChild(row) + } + + document.body.appendChild(menu) + + // Positioning + const rect = button.getBoundingClientRect() + menu.style.left = `${rect.left + window.scrollX}px` + menu.style.top = `${rect.bottom + window.scrollY}px` + + const closeMenu = (e) => { + if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) { + this.menu.remove() + this.menu = null + document.removeEventListener('mousedown', closeMenu) + } + } + document.addEventListener('mousedown', closeMenu) + } + + insertTable(cols, rows) { + // abstract + } + + precodeMenu(button) { + if (this.menu) { + this.menu.remove() + this.menu = null + return + } + + const menu = document.createElement('ul') + menu.className = 'drdn-items' + menu.style.position = 'absolute' + menu.style.zIndex = '1000' + menu.style.backgroundColor = '#fff' + menu.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)' + menu.style.border = '1px solid #d7d7d7' + menu.style.listStyle = 'none' + menu.style.padding = '5px 0' + menu.style.margin = '0' + menu.style.minWidth = '150px' + menu.style.maxHeight = '300px' + menu.style.overflowY = 'auto' + + this.menu = menu + + this.languagesValue.forEach(lang => { + const item = document.createElement('li') + const link = document.createElement('a') + link.href = '#' + link.textContent = lang + link.style.display = 'block' + link.style.padding = '4px 12px' + link.style.color = '#333' + link.style.textDecoration = 'none' + link.style.fontSize = '0.9em' + + link.addEventListener('mouseenter', () => { + link.style.backgroundColor = '#3e70ad' + link.style.color = '#fff' + }) + link.addEventListener('mouseleave', () => { + link.style.backgroundColor = 'transparent' + link.style.color = '#333' + }) + + link.addEventListener('mousedown', (e) => { + e.preventDefault() + this.insertPrecode(lang) + this.menu.remove() + this.menu = null + }) + + item.appendChild(link) + menu.appendChild(item) + }) + + document.body.appendChild(menu) + + // Positioning + const rect = button.getBoundingClientRect() + menu.style.left = `${rect.left + window.scrollX}px` + menu.style.top = `${rect.bottom + window.scrollY}px` + + const closeMenu = (e) => { + if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) { + this.menu.remove() + this.menu = null + document.removeEventListener('mousedown', closeMenu) + } + } + document.addEventListener('mousedown', closeMenu) + } + + insertPrecode(lang) { + // abstract + } +} diff --git a/app/views/issues/_edit.html.erb b/app/views/issues/_edit.html.erb index 574d8e674..5e61a8863 100644 --- a/app/views/issues/_edit.html.erb +++ b/app/views/issues/_edit.html.erb @@ -30,12 +30,13 @@ <% end %> <% if @issue.notes_addable? %>
<%= l(:field_notes) %> - <%= f.textarea :notes, :cols => 60, :rows => 10, :class => 'wiki-edit', + <%= wikitoolbar 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) do %> + <%= f.textarea :notes, :cols => 60, :rows => 10, :data => { :auto_complete => true }.merge(list_autofill_data_attributes), :no_label => true %> - <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %> + <% end %> <% if @issue.safe_attribute? 'private_notes' %> <%= f.check_box :private_notes, :no_label => true %> diff --git a/app/views/issues/_form.html.erb b/app/views/issues/_form.html.erb index 4ef58d657..03844cf89 100644 --- a/app/views/issues/_form.html.erb +++ b/app/views/issues/_form.html.erb @@ -30,9 +30,10 @@ <% end %> <% if @issue.safe_attribute? 'description' %> -

+

<%= f.label_for_field :description, :required => @issue.required_attribute?('description') %> <%= content_tag 'span', :id => "issue_description_and_toolbar", :style => (@issue.new_record? ? nil : 'display:none') do %> + <%= wikitoolbar 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) do |t| %> <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit', :rows => [[10, @issue.description.to_s.length / 50].max, 20].min, :data => { @@ -40,9 +41,9 @@ }.merge(list_autofill_data_attributes), :no_label => true %> <% end %> + <% end %> <%= link_to_function content_tag(:span, sprite_icon('edit', l(:button_edit))), '$(this).hide(); $("#issue_description_and_toolbar").show()', :class => 'icon icon-edit' unless @issue.new_record? %> -

-<%= wikitoolbar_for 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) %> +
<% end %>
@@ -52,7 +53,6 @@ <%= call_hook(:view_issues_form_details_bottom, { :issue => @issue, :form => f }) %> <% end %> -<% heads_for_wiki_formatter %> <%= heads_for_auto_complete(@issue.project) %> <% if User.current.allowed_to?(:add_issue_watchers, @issue.project)%> diff --git a/app/views/issues/_form_custom_fields.html.erb b/app/views/issues/_form_custom_fields.html.erb index fddcd742f..e110fe670 100644 --- a/app/views/issues/_form_custom_fields.html.erb +++ b/app/views/issues/_form_custom_fields.html.erb @@ -20,5 +20,5 @@ <% custom_field_values_full_width.each do |value| %>

<%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %>

- <%= wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %> + <%# wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %> <% end %> diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 2b9e95cfe..877603102 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -8,7 +8,7 @@ <%= csrf_meta_tag %> <%= favicon %> -<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'responsive', :media => 'all' %> +<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'wiki_editor', 'responsive', :media => 'all' %> <%= javascript_importmap_tags %> <%= javascript_heads %> <%= heads_for_theme %> diff --git a/config/locales/en.yml b/config/locales/en.yml index e99f01f74..ad92d139e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -888,6 +888,25 @@ en: label_index_by_date: Index by date label_current_version: Current version label_preview: Preview + label_wiki_toolbar_strong: Strong + label_wiki_toolbar_italic: Italic + label_wiki_toolbar_underline: Underline + label_wiki_toolbar_deleted: Deleted + label_wiki_toolbar_code: Inline Code + label_wiki_toolbar_heading_1: Heading 1 + label_wiki_toolbar_heading_2: Heading 2 + label_wiki_toolbar_heading_3: Heading 3 + label_wiki_toolbar_unordered_list: Unordered list + label_wiki_toolbar_ordered_list: Ordered list + label_wiki_toolbar_task_list: Task list + label_wiki_toolbar_quote: Quote + label_wiki_toolbar_unquote: Remove quote + label_wiki_toolbar_table: Table + label_wiki_toolbar_preformatted_text: Preformatted text + label_wiki_toolbar_preformatted_code: Highlighted code + label_wiki_toolbar_wiki_link: Link to a Wiki page + label_wiki_toolbar_image: Image + label_wiki_toolbar_help: Help label_feed_plural: Feeds label_changes_details: Details of all changes label_issue_tracking: Issue tracking diff --git a/lib/redmine/wiki_formatting/common_mark/helper.rb b/lib/redmine/wiki_formatting/common_mark/helper.rb index f75f1fc0e..f616352a9 100644 --- a/lib/redmine/wiki_formatting/common_mark/helper.rb +++ b/lib/redmine/wiki_formatting/common_mark/helper.rb @@ -21,6 +21,38 @@ module Redmine module WikiFormatting module CommonMark module Helper + def wikitoolbar_controller_name + 'common-mark-toolbar' + end + + def wikitoolbar_buttons + [ + { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' }, + { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' }, + { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' }, + { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' }, + { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' }, + { class: 'jstSpacer' }, + { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' }, + { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' }, + { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' }, + { class: 'jstSpacer' }, + { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' }, + { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' }, + { label: :label_wiki_toolbar_task_list, class: 'jstb_tl', icon: 'list-check', action: 'taskList' }, + { class: 'jstSpacer' }, + { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' }, + { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' }, + { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' }, + { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' }, + { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' }, + { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' }, + { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' }, + { class: 'spacer' }, + { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' } + ] + end + def wikitoolbar_for(field_id, preview_url = preview_text_path) heads_for_wiki_formatter diff --git a/lib/redmine/wiki_formatting/textile/helper.rb b/lib/redmine/wiki_formatting/textile/helper.rb index ad6e53992..91c05f2c0 100644 --- a/lib/redmine/wiki_formatting/textile/helper.rb +++ b/lib/redmine/wiki_formatting/textile/helper.rb @@ -21,6 +21,10 @@ module Redmine module WikiFormatting module Textile module Helper + def wikitoolbar_controller_name + 'textile-toolbar' + end + def wikitoolbar_for(field_id, preview_url = preview_text_path) heads_for_wiki_formatter @@ -56,6 +60,33 @@ module Redmine @heads_for_wiki_formatter_included = true end end + + def wikitoolbar_buttons + [ + { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' }, + { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' }, + { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' }, + { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' }, + { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' }, + { class: 'spacer' }, + { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' }, + { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' }, + { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' }, + { class: 'spacer' }, + { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' }, + { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' }, + { class: 'spacer' }, + { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' }, + { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' }, + { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' }, + { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' }, + { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' }, + { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' }, + { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' }, + { class: 'spacer' }, + { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' } + ] + end end end end diff --git a/test/unit/lib/redmine/views/labelled_form_builder_test.rb b/test/unit/lib/redmine/views/labelled_form_builder_test.rb index 11c3409a2..35a3af6af 100644 --- a/test/unit/lib/redmine/views/labelled_form_builder_test.rb +++ b/test/unit/lib/redmine/views/labelled_form_builder_test.rb @@ -47,4 +47,14 @@ class Redmine::Views::LabelledFormBuilderTest < Redmine::HelperTest assert_include 'value="2.z"', f.hours_field(:hours) end end + + def test_wiki_textarea + issue = Issue.new(:description => 'test description') + labelled_form_for(issue) do |f| + output = f.wiki_textarea(:description) + assert_include '', output + assert_include '