Project

General

Profile

Patch #43259 » 0001-wip.patch

Marius BĂLTEANU, 2026-02-21 19:43

View differences:

app/assets/images/icons.svg
82 82
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--attachment">
83 83
      <path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"/>
84 84
    </symbol>
85
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bold">
86
      <path d="M7 5h6a3.5 3.5 0 0 1 0 7h-6z"/>
87
      <path d="M13 12h1a3.5 3.5 0 0 1 0 7h-7v-7"/>
88
    </symbol>
85 89
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--bookmark-add">
86 90
      <path d="M12 17l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v5"/>
87 91
      <path d="M16 19h6"/>
......
235 239
      <path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/>
236 240
      <path d="M3 13v-1a2 2 0 0 1 2 -2h2"/>
237 241
    </symbol>
242
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--h1">
243
      <path d="M19 18v-8l-2 2"/>
244
      <path d="M4 6v12"/>
245
      <path d="M12 6v12"/>
246
      <path d="M11 18h2"/>
247
      <path d="M3 18h2"/>
248
      <path d="M4 12h8"/>
249
      <path d="M3 6h2"/>
250
      <path d="M11 6h2"/>
251
    </symbol>
252
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--h2">
253
      <path d="M17 12a2 2 0 1 1 4 0c0 .591 -.417 1.318 -.816 1.858l-3.184 4.143l4 0"/>
254
      <path d="M4 6v12"/>
255
      <path d="M12 6v12"/>
256
      <path d="M11 18h2"/>
257
      <path d="M3 18h2"/>
258
      <path d="M4 12h8"/>
259
      <path d="M3 6h2"/>
260
      <path d="M11 6h2"/>
261
    </symbol>
262
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--h3">
263
      <path d="M19 14a2 2 0 1 0 -2 -2"/>
264
      <path d="M17 16a2 2 0 1 0 2 -2"/>
265
      <path d="M4 6v12"/>
266
      <path d="M12 6v12"/>
267
      <path d="M11 18h2"/>
268
      <path d="M3 18h2"/>
269
      <path d="M4 12h8"/>
270
      <path d="M3 6h2"/>
271
      <path d="M11 6h2"/>
272
    </symbol>
238 273
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--help">
239 274
      <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/>
240 275
      <path d="M12 9h.01"/>
......
258 293
      <path d="M16 19h6"/>
259 294
      <path d="M19 16l3 3l-3 3"/>
260 295
    </symbol>
296
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--indent-decrease">
297
      <path d="M20 6l-7 0"/>
298
      <path d="M20 12l-9 0"/>
299
      <path d="M20 18l-7 0"/>
300
      <path d="M8 8l-4 4l4 4"/>
301
    </symbol>
302
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--indent-increase">
303
      <path d="M20 6l-11 0"/>
304
      <path d="M20 12l-7 0"/>
305
      <path d="M20 18l-11 0"/>
306
      <path d="M4 8l4 4l-4 4"/>
307
    </symbol>
308
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--inline-code">
309
      <path d="M18 9a5 5 0 0 0 -5 -5h-2a5 5 0 0 0 -5 5v6a5 5 0 0 0 5 5h2a5 5 0 0 0 5 -5"/>
310
    </symbol>
261 311
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--issue">
262 312
      <path d="M13 20l7 -7"/>
263 313
      <path d="M13 20v-6a1 1 0 0 1 1 -1h6v-7a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7"/>
......
278 328
      <path d="M16 19h6"/>
279 329
      <path d="M19 16v6"/>
280 330
    </symbol>
331
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--italic">
332
      <path d="M11 5l6 0"/>
333
      <path d="M7 19l6 0"/>
334
      <path d="M14 5l-4 14"/>
335
    </symbol>
281 336
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--key">
282 337
      <path d="M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.172a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.172a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0z"/>
283 338
      <path d="M15 9h.01"/>
......
301 356
      <path d="M5 12l0 .01"/>
302 357
      <path d="M5 18l0 .01"/>
303 358
    </symbol>
359
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--list-check">
360
      <path d="M3.5 5.5l1.5 1.5l2.5 -2.5"/>
361
      <path d="M3.5 11.5l1.5 1.5l2.5 -2.5"/>
362
      <path d="M3.5 17.5l1.5 1.5l2.5 -2.5"/>
363
      <path d="M11 6l9 0"/>
364
      <path d="M11 12l9 0"/>
365
      <path d="M11 18l9 0"/>
366
    </symbol>
367
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--list-numbers">
368
      <path d="M11 6h9"/>
369
      <path d="M11 12h9"/>
370
      <path d="M12 18h8"/>
371
      <path d="M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4"/>
372
      <path d="M6 10v-6l-2 2"/>
373
    </symbol>
304 374
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--loader">
305 375
      <path d="M12 3a9 9 0 1 0 9 9"/>
306 376
    </symbol>
......
434 504
      <path d="M9 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/>
435 505
      <path d="M4 20h14"/>
436 506
    </symbol>
507
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--strikethrough">
508
      <path d="M5 12l14 0"/>
509
      <path d="M16 6.5a4 2 0 0 0 -4 -1.5h-1a3.5 3.5 0 0 0 0 7h2a3.5 3.5 0 0 1 0 7h-1.5a4 2 0 0 1 -4 -1.5"/>
510
    </symbol>
437 511
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--summary">
438 512
      <path d="M13 3l0 7l6 0l-8 11l0 -7l-6 0l8 -11"/>
439 513
    </symbol>
514
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--table">
515
      <path d="M12.5 21h-7.5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v7.5"/>
516
      <path d="M3 10h18"/>
517
      <path d="M10 3v18"/>
518
      <path d="M16 19h6"/>
519
      <path d="M19 16v6"/>
520
    </symbol>
440 521
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--table-multiple">
441 522
      <path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/>
442 523
      <path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>
......
534 615
      <path d="M15 12h-6"/>
535 616
      <path d="M12 9v6"/>
536 617
    </symbol>
618
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--underline">
619
      <path d="M7 5v5a5 5 0 0 0 10 0v-5"/>
620
      <path d="M5 19h14"/>
621
    </symbol>
537 622
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--unlock">
538 623
      <path d="M5 11m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"/>
539 624
      <path d="M12 16m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/>
......
562 647
      <path d="M15.066 20.502a4 4 0 1 0 1.934 -7.502c-.706 0 -1.424 .179 -2 .5l-3 -5.5"/>
563 648
      <path d="M16 8a4 4 0 1 0 -8 0c0 1.506 .77 2.818 2 3.5l-3 5.5"/>
564 649
    </symbol>
650
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-link">
651
      <path d="M15.536 17.586a2.123 2.123 0 0 0 -2.929 0a1.951 1.951 0 0 0 0 2.828c.809 .781 2.12 .781 2.929 0c.809 -.781 -.805 .778 0 0l1.46 -1.41l1.46 -1.419"/>
652
      <path d="M15.54 17.582l1.46 1.42l1.46 1.41c.809 .78 -.805 -.779 0 0s2.12 .781 2.929 0a1.951 1.951 0 0 0 0 -2.828a2.123 2.123 0 0 0 -2.929 0"/>
653
      <path d="M14 3v4a1 1 0 0 0 1 1h4"/>
654
      <path d="M9.5 21h-2.5a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v6"/>
655
    </symbol>
565 656
    <symbol viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" id="icon--wiki-page">
566 657
      <path d="M6 4h11a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-11a1 1 0 0 1 -1 -1v-14a1 1 0 0 1 1 -1m3 0v18"/>
567 658
      <path d="M13 8l2 0"/>
app/assets/stylesheets/jstoolbar.css
144 144
.jstb_unbq {
145 145
    background-image: url(/jstoolbar/indent-decrease.svg);
146 146
}
147
.jstb_pre::before {
147
.jstBlock .jstb_pre::before {
148 148
    content: "pre";
149 149
    font-size: 10px;
150 150
    color: var(--oc-gray-9);
app/assets/stylesheets/wiki_editor.css
1
.wiki-editor {
2
  border: 1px solid #d7d7d7;
3
  border-radius: 3px;
4
  margin-bottom: 10px;
5
  background: #fff;
6
}
7

  
8
.wiki-editor:has(.wiki-editor-edit:not(.hidden)):focus-within {
9
  border-color: var(--oc-blue-5);
10
}
11

  
12
.wiki-editor-header {
13
  display: flex;
14
  flex-wrap: wrap;
15
  align-items: flex-end;
16
  /* Align tabs to bottom */
17
  justify-content: flex-start;
18
  padding-left: 0.5em;
19
  /* Remove bottom padding */
20
  background-color: #f6f6f6;
21
  border-bottom: 1px solid #d7d7d7;
22
  gap: 10px;
23
}
24

  
25
.wiki-editor-tabs {
26
  display: flex;
27
  align-items: stretch;
28
  gap: 4px;
29
  /* No gap between tabs */
30
}
31

  
32
.wiki-editor-tab {
33
  border: 1px solid transparent;
34
  height: auto;
35
  border-bottom: none;
36
  /* No bottom border to merge with header border */
37
  background: none;
38
  padding: 6px 12px;
39
  border-radius: 3px 3px 0 0;
40
  cursor: pointer;
41
  color: #555;
42
  font-size: 0.9em;
43
  font-weight: 500;
44
  margin-bottom: -1px;
45
  margin-top: -1px;
46
}
47

  
48
.wiki-editor-tab:hover {
49
  background-color: rgba(0, 0, 0, 0.05);
50
  color: #333;
51
}
52

  
53
.wiki-editor-tab.selected {
54
  background-color: #fff;
55
  border-color: #d7d7d7;
56
  color: #222;
57
  font-weight: 600;
58
  z-index: 2;
59
  /* Bring above header border */
60
}
61

  
62
.wiki-editor-buttons {
63
  display: flex;
64
  align-items: center;
65
  flex-wrap: wrap;
66
  gap: 2px;
67
  padding-bottom: 0;
68
}
69

  
70
.wiki-editor-buttons.hidden {
71
  display: none;
72
}
73

  
74
.wiki-editor-buttons button {
75
  background: none;
76
  border: 1px solid transparent;
77
  border-radius: 3px;
78
  padding: 2px;
79
  cursor: pointer;
80
  min-width: 24px;
81
  height: 24px;
82
  display: flex;
83
  align-items: center;
84
  justify-content: center;
85
  color: #555;
86
}
87

  
88
.wiki-editor-buttons button:hover {
89
  background-color: #e6e6e6;
90
  border-color: #d0d0d0;
91
  color: #111;
92
}
93

  
94
.wiki-editor-buttons button svg {
95
  stroke-width: 2;
96
}
97

  
98
/* Icons are usually background images or fonts in Redmine.
99
   Assuming render_wikitoolbar_buttons outputs buttons with classes that have background icons.
100
   Existing jstoolbar.css usually handles .jstb_strong etc.
101
   We might need to ensure those classes still work or adapt them.
102
   For now, I'm setting structural CSS.
103
*/
104

  
105
.wiki-editor-pane {
106
  padding: 0;
107
}
108

  
109
.wiki-editor-pane textarea {
110
  width: 100%;
111
  border: none;
112
  padding: 10px;
113
  box-sizing: border-box;
114
  min-height: 200px;
115
  resize: vertical;
116
  display: block;
117
  outline: none;
118
  /* Let the container border handle focus focus indicator potentially, or keep default */
119
}
120

  
121
/* Adjustments for Redmine default styles overriding */
122
/* .wiki-editor-pane textarea:focus {
123
  box-shadow: none;
124
  outline: none;
125
} */
126

  
127
.wiki-editor-preview {
128
  padding: 10px;
129
  min-height: 200px;
130
  background-color: #fff;
131
}
132

  
133
.wiki-editor-preview>p:first-child {
134
  padding-top: 0 !important;
135
  margin-top: 0 !important;
136
}
137

  
138
.wiki-editor-preview>p:last-child {
139
  padding-bottom: 0 !important;
140
  margin-bottom: 0 !important;
141
}
142

  
143
/* Container alignment fix for tabular forms */
144
.tabular .wiki-edit-container {
145
  margin: 0;
146
  padding: 3px 0 3px 0;
147
  padding-left: 180px;
148
  min-height: 2em;
149
  clear: left;
150
  overflow: hidden;
151
}
152

  
153
/* Table Generator Picker */
154
.table-generator {
155
  position: absolute;
156
  border-collapse: collapse;
157
  z-index: 1000;
158
  background-color: #fff;
159
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
160
  border: 1px solid #d7d7d7;
161
}
162

  
163
.table-generator td {
164
  border: 1px solid #eee;
165
  padding: 10px;
166
  cursor: pointer;
167
  width: 10px;
168
  height: 10px;
169
}
170

  
171
.table-generator td.selected-cell {
172
  background-color: var(--oc-blue-1);
173
  border-color: var(--oc-blue-3);
174
}
app/helpers/application_helper.rb
29 29
  include Redmine::Hook::Helper
30 30
  include Redmine::Helpers::URL
31 31
  include IconsHelper
32
  include WikiToolbarHelper
33
  include BrowserHelper
32 34

  
33 35
  extend Forwardable
34
  def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
36
  def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter, :wikitoolbar_buttons, :wikitoolbar_controller_name
35 37

  
36 38
  # Return true if user is authorized for controller/action, otherwise false
37 39
  def authorize_for(controller, action)
app/helpers/browser_helper.rb
1
# frozen_string_literal: true
2

  
3
module BrowserHelper
4
  def mac_os?
5
    request.user_agent.downcase.include?('macintosh')
6
  end
7

  
8
  def modifier_key
9
    mac_os? ? '⌘' : 'Ctrl'
10
  end
11
end
app/helpers/wiki_toolbar_helper.rb
1
# frozen_string_literal: true
2

  
3
module WikiToolbarHelper
4
  def wikitoolbar(field_id, preview_url = preview_text_path, &block)
5
    controller_name = wikitoolbar_controller_name
6

  
7
    content_tag(:div,
8
                class: 'wiki-editor',
9
                data: {
10
                  controller: controller_name,
11
                  field_id: field_id,
12
                  "#{controller_name}-preview-url-value": preview_url,
13
                  "#{controller_name}-help-url-value": help_wiki_syntax_path,
14
                  "#{controller_name}-languages-value": (User.current && User.current.pref.toolbar_language_options || UserPreference::DEFAULT_TOOLBAR_LANGUAGE_OPTIONS).split(',')
15
                }) do
16
      concat(
17
        content_tag(:div, class: 'wiki-editor-header') do
18
          concat(
19
            content_tag(:div, class: 'wiki-editor-tabs') do
20
              concat button_tag(l(:button_edit), type: 'button', class: 'wiki-editor-tab selected', data: { action: "#{controller_name}#edit", "#{controller_name}-target": "editTab" })
21
              concat button_tag(l(:label_preview), type: 'button', class: 'wiki-editor-tab', data: { action: "#{controller_name}#preview", "#{controller_name}-target": "previewTab" })
22
            end
23
          )
24
          concat(
25
            content_tag(:div, class: 'wiki-editor-buttons', data: { "#{controller_name}-target": "buttons" }) do
26
              render_wikitoolbar_buttons
27
            end
28
          )
29
        end
30
      )
31

  
32
      concat(
33
        content_tag(:div, class: 'wiki-editor-pane wiki-editor-edit', data: { "#{controller_name}-target": "editPane" }, &block)
34
      )
35

  
36
      concat(
37
        content_tag(:div, '', class: 'wiki-editor-pane wiki-editor-preview wiki hidden', data: { "#{controller_name}-target": "previewPane" })
38
      )
39
    end
40
  end
41

  
42
  def render_wikitoolbar_buttons
43
    buttons = wikitoolbar_buttons
44
    controller_name = wikitoolbar_controller_name
45

  
46
    buttons.map do |button|
47
      if button[:class] == 'jstSpacer' || button[:class] == 'spacer'
48
        content_tag('span', '', class: 'jstSpacer')
49
      else
50
        title = l(button[:label])
51
        title += " (#{modifier_key}+#{button[:shortcut].upcase})" if button[:shortcut]
52

  
53
        tag.button(
54
          wikitoolbar_button_content(button, title),
55
          type: 'button',
56
          class: button[:class],
57
          title: title,
58
          data: { action: "#{controller_name}##{button[:action]}" }
59
        )
60
      end
61
    end.join.html_safe
62
  end
63

  
64
  private
65

  
66
  def wikitoolbar_button_content(button, title)
67
    if button[:icon] == 'pre'
68
      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',
69
'stroke-linejoin': 'round') do
70
        concat tag.rect(x: '3', y: '5', width: '18', height: '14', rx: '2')
71
        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')
72
      end
73
    else
74
      sprite_icon(button[:icon], title, icon_only: true, size: 16)
75
    end
76
  end
77
end
app/javascript/controllers/common_mark_toolbar_controller.js
1
import WikiToolbarController from "controllers/wiki_toolbar_controller"
2

  
3
export default class extends WikiToolbarController {
4
    strong() {
5
        this.singleTag('**')
6
    }
7

  
8
    italic() {
9
        this.singleTag('*')
10
    }
11

  
12
    underline() {
13
        this.singleTag('<u>', '</u>')
14
    }
15

  
16
    deleted() {
17
        this.singleTag('~~')
18
    }
19

  
20
    inlineCode() {
21
        this.singleTag('`')
22
    }
23

  
24
    h1() {
25
        this.encloseLineSelection('# ', '', (str) => {
26
            return str.replace(/^#+\s+/, '')
27
        })
28
    }
29

  
30
    h2() {
31
        this.encloseLineSelection('## ', '', (str) => {
32
            return str.replace(/^#+\s+/, '')
33
        })
34
    }
35

  
36
    h3() {
37
        this.encloseLineSelection('### ', '', (str) => {
38
            return str.replace(/^#+\s+/, '')
39
        })
40
    }
41

  
42
    unorderedList() {
43
        this.encloseLineSelection('', '', (str) => {
44
            str = str.replace(/\r/g, '')
45
            return str.replace(/(\n|^)[#-]?\s*/g, "$1* ")
46
        })
47
    }
48

  
49
    orderedList() {
50
        this.encloseLineSelection('', '', (str) => {
51
            str = str.replace(/\r/g, '')
52
            return str.replace(/(\n|^)[*-]?\s*/g, "$11. ")
53
        })
54
    }
55

  
56
    taskList() {
57
        this.encloseLineSelection('', '', (str) => {
58
            str = str.replace(/\r/g, '')
59
            return str.replace(/(\n|^)[*-]?\s*/g, "$1* [ ] ")
60
        })
61
    }
62

  
63
    quote() {
64
        this.encloseLineSelection('', '', (str) => {
65
            str = str.replace(/\r/g, '')
66
            return str.replace(/(\n|^)( *)([^\n]*)/g, "$1> $2$3")
67
        })
68
    }
69

  
70
    unquote() {
71
        this.encloseLineSelection('', '', (str) => {
72
            str = str.replace(/\r/g, '')
73
            return str.replace(/(\n|^) *(> ?)?( *)([^\n]*)/g, "$1$3$4")
74
        })
75
    }
76

  
77
    pre() {
78
        this.encloseLineSelection('```\n', '\n```')
79
    }
80

  
81
    image() {
82
        this.encloseSelection("![](", ")")
83
    }
84

  
85
    insertTable(cols, rows) {
86
        const alphabets = "ABCDEFGHIJ".split('')
87
        const header = '|' + alphabets.slice(0, cols).join(' |') + ' |\n'
88
        const separator = Array(cols + 1).join('|--') + '|\n'
89
        const cells = Array(rows + 1).join(Array(cols + 1).join('|  ') + '|\n')
90
        this.encloseLineSelection(header + separator + cells, '')
91
    }
92

  
93
    insertPrecode(lang) {
94
        this.encloseLineSelection('``` ' + lang + '\n', '\n```\n')
95
    }
96
}
app/javascript/controllers/textile_toolbar_controller.js
1
import WikiToolbarController from "controllers/wiki_toolbar_controller"
2

  
3
export default class extends WikiToolbarController {
4
    strong() {
5
        this.singleTag('*')
6
    }
7

  
8
    italic() {
9
        this.singleTag('_')
10
    }
11

  
12
    underline() {
13
        this.singleTag('+')
14
    }
15

  
16
    deleted() {
17
        this.singleTag('-')
18
    }
19

  
20
    inlineCode() {
21
        this.singleTag('@')
22
    }
23

  
24
    h1() {
25
        this.encloseLineSelection('h1. ', '', (str) => {
26
            return str.replace(/^h\d+\.\s+/, '')
27
        })
28
    }
29

  
30
    h2() {
31
        this.encloseLineSelection('h2. ', '', (str) => {
32
            return str.replace(/^h\d+\.\s+/, '')
33
        })
34
    }
35

  
36
    h3() {
37
        this.encloseLineSelection('h3. ', '', (str) => {
38
            return str.replace(/^h\d+\.\s+/, '')
39
        })
40
    }
41

  
42
    unorderedList() {
43
        this.encloseLineSelection('', '', (str) => {
44
            str = str.replace(/\r/g, '')
45
            return str.replace(/(\n|^)[#-]?\s*/g, "$1* ")
46
        })
47
    }
48

  
49
    orderedList() {
50
        this.encloseLineSelection('', '', (str) => {
51
            str = str.replace(/\r/g, '')
52
            return str.replace(/(\n|^)[*-]?\s*/g, "$1# ")
53
        })
54
    }
55

  
56
    quote() {
57
        this.encloseLineSelection('', '', (str) => {
58
            str = str.replace(/\r/g, '')
59
            return str.replace(/(\n|^)( *)([^\n]*)/g, "$1> $2$3")
60
        })
61
    }
62

  
63
    unquote() {
64
        this.encloseLineSelection('', '', (str) => {
65
            str = str.replace(/\r/g, '')
66
            return str.replace(/(\n|^) *(> ?)?( *)([^\n]*)/g, "$1$3$4")
67
        })
68
    }
69

  
70
    pre() {
71
        this.encloseLineSelection('<pre>\n', '\n</pre>')
72
    }
73

  
74
    image() {
75
        this.encloseSelection("!", "!")
76
    }
77

  
78
    insertTable(cols, rows) {
79
        const alphabets = "ABCDEFGHIJ".split('')
80
        const header = '|_.' + alphabets.slice(0, cols).join('|_.') + '|\n'
81
        const cells = Array(rows + 1).join(Array(cols + 1).join('|  ') + '|\n')
82
        this.encloseLineSelection(header + cells, '')
83
    }
84

  
85
    insertPrecode(lang) {
86
        this.encloseLineSelection('<pre><code class="' + lang + '">\n', '\n</code></pre>\n')
87
    }
88
}
app/javascript/controllers/wiki_toolbar_controller.js
1
import { Controller } from "@hotwired/stimulus"
2

  
3
export default class extends Controller {
4

  
5
  static targets = ["field", "previewPane", "editPane", "editTab", "previewTab", "buttons"]
6
  static values = {
7
    languages: Array,
8
    previewUrl: String,
9
    helpUrl: String
10
  }
11

  
12
  connect() {
13
    this.field = this.hasFieldTarget ? this.fieldTarget : document.getElementById(this.element.dataset.fieldId)
14
    if (!this.field) return
15

  
16
    this.setupShortcuts()
17
  }
18

  
19
  strong() {
20
    // abstract
21
  }
22

  
23
  italic() {
24
    // abstract
25
  }
26

  
27
  underline() {
28
    // abstract
29
  }
30

  
31
  deleted() {
32
    // abstract
33
  }
34

  
35
  inlineCode() {
36
    // abstract
37
  }
38

  
39
  h1() {
40
    // abstract
41
  }
42

  
43
  h2() {
44
    // abstract
45
  }
46

  
47
  h3() {
48
    // abstract
49
  }
50

  
51
  unorderedList() {
52
    // abstract
53
  }
54

  
55
  orderedList() {
56
    // abstract
57
  }
58

  
59
  quote() {
60
    // abstract
61
  }
62

  
63
  unquote() {
64
    // abstract
65
  }
66

  
67
  pre() {
68
    // abstract
69
  }
70

  
71
  help() {
72
    window.open(this.helpUrlValue, "_blank")
73
  }
74

  
75
  precode(event) {
76
    event.preventDefault()
77
    this.precodeMenu(event.currentTarget)
78
  }
79

  
80
  table(event) {
81
    event.preventDefault()
82
    this.tableMenu(event.currentTarget)
83
  }
84

  
85
  wikiLink() {
86
    this.encloseSelection("[[", "]]")
87
  }
88

  
89
  image() {
90
    // abstract
91
  }
92

  
93
  // Common text manipulation methods
94
  singleTag(startTag, endTag) {
95
    endTag = endTag || startTag
96
    this.encloseSelection(startTag, endTag)
97
  }
98

  
99
  encloseSelection(prefix, suffix, fn) {
100
    this.field.focus()
101
    prefix = prefix || ''
102
    suffix = suffix || ''
103

  
104
    let start = this.field.selectionStart
105
    let end = this.field.selectionEnd
106
    let scrollPos = this.field.scrollTop
107
    let sel = this.field.value.substring(start, end)
108
    let subst
109

  
110
    if (start > 0 && this.field.value.charAt(start - 1).match(/\S/)) {
111
      prefix = ' ' + prefix
112
    }
113
    if (this.field.value.charAt(end).match(/\S/)) {
114
      suffix = suffix + ' '
115
    }
116

  
117
    if (sel.match(/ $/)) {
118
      sel = sel.substring(0, sel.length - 1)
119
      suffix = suffix + " "
120
    }
121

  
122
    let res = (typeof fn === 'function') ? ((sel) ? fn.call(this, sel) : fn('')) : (sel ? sel : '')
123
    subst = prefix + res + suffix
124

  
125
    this.field.setRangeText(subst, start, end, 'select')
126
    this.field.selectionStart = start + prefix.length
127
    this.field.selectionEnd = start + prefix.length + res.length
128
    this.field.scrollTop = scrollPos
129
  }
130

  
131
  encloseLineSelection(prefix, suffix, fn) {
132
    this.field.focus()
133
    prefix = prefix || ''
134
    suffix = suffix || ''
135

  
136
    let start = this.field.selectionStart
137
    let end = this.field.selectionEnd
138
    let scrollPos = this.field.scrollTop
139

  
140
    // Go to start of line
141
    while (start > 0 && this.field.value.charAt(start - 1) !== '\n' && this.field.value.charAt(start - 1) !== '\r') {
142
      start--
143
    }
144

  
145
    // Go to end of line
146
    while (end < this.field.value.length && this.field.value.charAt(end) !== '\n' && this.field.value.charAt(end) !== '\r') {
147
      end++
148
    }
149

  
150
    let sel = this.field.value.substring(start, end)
151
    if (sel.match(/ $/)) {
152
      sel = sel.substring(0, sel.length - 1)
153
      suffix = suffix + " "
154
    }
155

  
156
    let res = (typeof fn === 'function') ? ((sel) ? fn.call(this, sel) : fn('')) : (sel ? sel : '')
157
    let subst = prefix + res + suffix
158

  
159
    this.field.setRangeText(subst, start, end, 'select')
160
    this.field.selectionStart = start + prefix.length
161
    this.field.selectionEnd = start + prefix.length + res.length
162
    this.field.scrollTop = scrollPos
163
  }
164

  
165
  setupShortcuts() {
166
    this.field.addEventListener('keydown', (e) => {
167
      if (this.isModifierKey(e)) {
168
        const key = e.key.toLowerCase()
169
        const shortcuts = {
170
          'b': 'strong',
171
          'i': 'italic',
172
          'u': 'underline'
173
        }
174
        if (shortcuts[key]) {
175
          e.preventDefault()
176
          this[shortcuts[key]]()
177
        }
178
      }
179
    })
180
  }
181

  
182
  isModifierKey(e) {
183
    const isMac = navigator.platform.toLowerCase().indexOf('mac') > -1
184
    return isMac ? e.metaKey : e.ctrlKey
185
  }
186

  
187
  async preview(event) {
188
    event.preventDefault()
189
    const previewTab = event.currentTarget
190
    if (previewTab.classList.contains('selected')) return
191

  
192
    const formData = new FormData()
193
    formData.append('text', this.field.value)
194
    const form = this.field.closest('form')
195
    const attachmentInputs = form.querySelectorAll('.attachments_fields input');
196

  
197
    attachmentInputs.forEach(input => {
198
      if (input.name && input.value) {
199
        formData.append(input.name, input.value);
200
      }
201
    });
202

  
203
    // Add authenticity token
204
    const token = document.querySelector('meta[name="csrf-token"]')
205
    if (token) {
206
      formData.append('authenticity_token', token.content)
207
    }
208

  
209
    try {
210
      const response = await fetch(this.previewUrlValue, {
211
        method: 'POST',
212
        body: formData,
213
        headers: {
214
          'X-Requested-With': 'XMLHttpRequest'
215
        }
216
      })
217

  
218
      if (response.ok) {
219
        const html = await response.text()
220
        this.showPreview(html)
221
      }
222
    } catch (error) {
223
      console.error("Preview failed", error)
224
    }
225
  }
226

  
227
  edit(event) {
228
    event.preventDefault()
229
    const editTab = event.currentTarget
230
    if (editTab.classList.contains('selected')) return
231

  
232
    this.hidePreview(editTab)
233
  }
234

  
235
  showPreview(html) {
236
    // Find or create preview div
237
    this.previewPaneTarget.innerHTML = html
238
    this.editPaneTarget.classList.add('hidden')
239
    this.previewPaneTarget.classList.remove('hidden')
240
    this.editTabTarget.classList.remove('selected')
241
    this.previewTabTarget.classList.add('selected')
242
    this.buttonsTarget.classList.add('hidden')
243
  }
244

  
245
  hidePreview(editTab) {
246
    this.editPaneTarget.classList.remove('hidden')
247
    this.previewPaneTarget.classList.add('hidden')
248
    this.editTabTarget.classList.add('selected')
249
    this.previewTabTarget.classList.remove('selected')
250
    this.buttonsTarget.classList.remove('hidden')
251
  }
252

  
253
  tableMenu(button) {
254
    if (this.menu) {
255
      this.menu.remove()
256
      this.menu = null
257
      return
258
    }
259

  
260
    const alphabets = "ABCDEFGHIJ".split('')
261
    const menu = document.createElement('table')
262
    menu.className = 'table-generator'
263
    this.menu = menu
264

  
265
    for (let r = 1; r <= 5; r++) {
266
      const row = document.createElement('tr')
267
      for (let c = 1; c <= 10; c++) {
268
        const cell = document.createElement('td')
269
        cell.dataset.row = r
270
        cell.dataset.col = c
271
        cell.title = `${c}×${r}`
272

  
273
        cell.addEventListener('mousedown', (e) => {
274
          e.preventDefault()
275
          this.insertTable(c, r)
276
          this.menu.remove()
277
          this.menu = null
278
        })
279

  
280
        cell.addEventListener('mouseenter', () => {
281
          const cells = menu.querySelectorAll('td')
282
          cells.forEach(el => {
283
            if (parseInt(el.dataset.row) <= r && parseInt(el.dataset.col) <= c) {
284
              el.classList.add('selected-cell')
285
            } else {
286
              el.classList.remove('selected-cell')
287
            }
288
          })
289
        })
290

  
291
        row.appendChild(cell)
292
      }
293
      menu.appendChild(row)
294
    }
295

  
296
    document.body.appendChild(menu)
297

  
298
    // Positioning
299
    const rect = button.getBoundingClientRect()
300
    menu.style.left = `${rect.left + window.scrollX}px`
301
    menu.style.top = `${rect.bottom + window.scrollY}px`
302

  
303
    const closeMenu = (e) => {
304
      if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) {
305
        this.menu.remove()
306
        this.menu = null
307
        document.removeEventListener('mousedown', closeMenu)
308
      }
309
    }
310
    document.addEventListener('mousedown', closeMenu)
311
  }
312

  
313
  insertTable(cols, rows) {
314
    // abstract
315
  }
316

  
317
  precodeMenu(button) {
318
    if (this.menu) {
319
      this.menu.remove()
320
      this.menu = null
321
      return
322
    }
323

  
324
    const menu = document.createElement('ul')
325
    menu.className = 'drdn-items'
326
    menu.style.position = 'absolute'
327
    menu.style.zIndex = '1000'
328
    menu.style.backgroundColor = '#fff'
329
    menu.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'
330
    menu.style.border = '1px solid #d7d7d7'
331
    menu.style.listStyle = 'none'
332
    menu.style.padding = '5px 0'
333
    menu.style.margin = '0'
334
    menu.style.minWidth = '150px'
335
    menu.style.maxHeight = '300px'
336
    menu.style.overflowY = 'auto'
337

  
338
    this.menu = menu
339

  
340
    this.languagesValue.forEach(lang => {
341
      const item = document.createElement('li')
342
      const link = document.createElement('a')
343
      link.href = '#'
344
      link.textContent = lang
345
      link.style.display = 'block'
346
      link.style.padding = '4px 12px'
347
      link.style.color = '#333'
348
      link.style.textDecoration = 'none'
349
      link.style.fontSize = '0.9em'
350

  
351
      link.addEventListener('mouseenter', () => {
352
        link.style.backgroundColor = '#3e70ad'
353
        link.style.color = '#fff'
354
      })
355
      link.addEventListener('mouseleave', () => {
356
        link.style.backgroundColor = 'transparent'
357
        link.style.color = '#333'
358
      })
359

  
360
      link.addEventListener('mousedown', (e) => {
361
        e.preventDefault()
362
        this.insertPrecode(lang)
363
        this.menu.remove()
364
        this.menu = null
365
      })
366

  
367
      item.appendChild(link)
368
      menu.appendChild(item)
369
    })
370

  
371
    document.body.appendChild(menu)
372

  
373
    // Positioning
374
    const rect = button.getBoundingClientRect()
375
    menu.style.left = `${rect.left + window.scrollX}px`
376
    menu.style.top = `${rect.bottom + window.scrollY}px`
377

  
378
    const closeMenu = (e) => {
379
      if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) {
380
        this.menu.remove()
381
        this.menu = null
382
        document.removeEventListener('mousedown', closeMenu)
383
      }
384
    }
385
    document.addEventListener('mousedown', closeMenu)
386
  }
387

  
388
  insertPrecode(lang) {
389
    // abstract
390
  }
391
}
app/views/issues/_edit.html.erb
30 30
    <% end %>
31 31
    <% if @issue.notes_addable? %>
32 32
      <fieldset id="add_notes"><legend><%= l(:field_notes) %></legend>
33
      <%= f.textarea :notes, :cols => 60, :rows => 10, :class => 'wiki-edit',
33
      <%= wikitoolbar 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) do %>
34
        <%= f.textarea :notes, :cols => 60, :rows => 10,
34 35
              :data => {
35 36
                  :auto_complete => true
36 37
              }.merge(list_autofill_data_attributes),
37 38
              :no_label => true %>
38
      <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %>
39
      <% end %>
39 40

  
40 41
      <% if @issue.safe_attribute? 'private_notes' %>
41 42
      <%= f.check_box :private_notes, :no_label => true %> <label for="issue_private_notes"><%= l(:field_private_notes) %></label>
app/views/issues/_form.html.erb
30 30
<% end %>
31 31

  
32 32
<% if @issue.safe_attribute? 'description' %>
33
<p>
33
<div id="issue_description_and_toolbar_parent" class="wiki-edit-container">
34 34
  <%= f.label_for_field :description, :required => @issue.required_attribute?('description') %>
35 35
  <%= content_tag 'span', :id => "issue_description_and_toolbar", :style => (@issue.new_record? ? nil : 'display:none') do %>
36
    <%= wikitoolbar 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) do |t| %>
36 37
      <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit',
37 38
            :rows => [[10, @issue.description.to_s.length / 50].max, 20].min,
38 39
            :data => {
......
40 41
            }.merge(list_autofill_data_attributes),
41 42
            :no_label => true %>
42 43
    <% end %>
44
  <% end %>
43 45
  <%= 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? %>
44
</p>
45
<%= wikitoolbar_for 'issue_description', preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) %>
46
</div>
46 47
<% end %>
47 48

  
48 49
<div id="attributes" class="attributes">
......
52 53
<%= call_hook(:view_issues_form_details_bottom, { :issue => @issue, :form => f }) %>
53 54
<% end %>
54 55

  
55
<% heads_for_wiki_formatter %>
56 56
<%= heads_for_auto_complete(@issue.project) %>
57 57

  
58 58
<% if User.current.allowed_to?(:add_issue_watchers, @issue.project)%>
app/views/issues/_form_custom_fields.html.erb
20 20

  
21 21
<% custom_field_values_full_width.each do |value| %>
22 22
  <p><%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %></p>
23
  <%= 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? %>
23
  <%# 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? %>
24 24
<% end %>
app/views/layouts/base.html.erb
8 8
<meta name="keywords" content="issue,bug,tracker" />
9 9
<%= csrf_meta_tag %>
10 10
<%= favicon %>
11
<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'responsive', :media => 'all' %>
11
<%= stylesheet_link_tag 'jquery/jquery-ui-1.13.2', 'tribute-5.1.3', 'application', 'wiki_editor', 'responsive', :media => 'all' %>
12 12
<%= javascript_importmap_tags %>
13 13
<%= javascript_heads %>
14 14
<%= heads_for_theme %>
config/locales/en.yml
888 888
  label_index_by_date: Index by date
889 889
  label_current_version: Current version
890 890
  label_preview: Preview
891
  label_wiki_toolbar_strong: Strong
892
  label_wiki_toolbar_italic: Italic
893
  label_wiki_toolbar_underline: Underline
894
  label_wiki_toolbar_deleted: Deleted
895
  label_wiki_toolbar_code: Inline Code
896
  label_wiki_toolbar_heading_1: Heading 1
897
  label_wiki_toolbar_heading_2: Heading 2
898
  label_wiki_toolbar_heading_3: Heading 3
899
  label_wiki_toolbar_unordered_list: Unordered list
900
  label_wiki_toolbar_ordered_list: Ordered list
901
  label_wiki_toolbar_task_list: Task list
902
  label_wiki_toolbar_quote: Quote
903
  label_wiki_toolbar_unquote: Remove quote
904
  label_wiki_toolbar_table: Table
905
  label_wiki_toolbar_preformatted_text: Preformatted text
906
  label_wiki_toolbar_preformatted_code: Highlighted code
907
  label_wiki_toolbar_wiki_link: Link to a Wiki page
908
  label_wiki_toolbar_image: Image
909
  label_wiki_toolbar_help: Help
891 910
  label_feed_plural: Feeds
892 911
  label_changes_details: Details of all changes
893 912
  label_issue_tracking: Issue tracking
lib/redmine/wiki_formatting/common_mark/helper.rb
21 21
  module WikiFormatting
22 22
    module CommonMark
23 23
      module Helper
24
        def wikitoolbar_controller_name
25
          'common-mark-toolbar'
26
        end
27

  
28
        def wikitoolbar_buttons
29
          [
30
            { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' },
31
            { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' },
32
            { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' },
33
            { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' },
34
            { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' },
35
            { class: 'jstSpacer' },
36
            { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' },
37
            { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' },
38
            { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' },
39
            { class: 'jstSpacer' },
40
            { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' },
41
            { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' },
42
            { label: :label_wiki_toolbar_task_list, class: 'jstb_tl', icon: 'list-check', action: 'taskList' },
43
            { class: 'jstSpacer' },
44
            { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' },
45
            { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' },
46
            { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' },
47
            { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' },
48
            { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' },
49
            { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' },
50
            { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' },
51
            { class: 'spacer' },
52
            { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' }
53
          ]
54
        end
55

  
24 56
        def wikitoolbar_for(field_id, preview_url = preview_text_path)
25 57
          heads_for_wiki_formatter
26 58

  
lib/redmine/wiki_formatting/textile/helper.rb
21 21
  module WikiFormatting
22 22
    module Textile
23 23
      module Helper
24
        def wikitoolbar_controller_name
25
          'textile-toolbar'
26
        end
27

  
24 28
        def wikitoolbar_for(field_id, preview_url = preview_text_path)
25 29
          heads_for_wiki_formatter
26 30

  
......
56 60
            @heads_for_wiki_formatter_included = true
57 61
          end
58 62
        end
63

  
64
        def wikitoolbar_buttons
65
          [
66
            { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' },
67
            { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' },
68
            { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' },
69
            { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' },
70
            { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' },
71
            { class: 'spacer' },
72
            { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' },
73
            { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' },
74
            { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' },
75
            { class: 'spacer' },
76
            { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' },
77
            { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' },
78
            { class: 'spacer' },
79
            { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' },
80
            { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' },
81
            { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' },
82
            { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' },
83
            { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' },
84
            { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' },
85
            { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' },
86
            { class: 'spacer' },
87
            { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' }
88
          ]
89
        end
59 90
      end
60 91
    end
61 92
  end
test/unit/lib/redmine/views/labelled_form_builder_test.rb
47 47
      assert_include 'value="2.z"', f.hours_field(:hours)
48 48
    end
49 49
  end
50

  
51
  def test_wiki_textarea
52
    issue = Issue.new(:description => 'test description')
53
    labelled_form_for(issue) do |f|
54
      output = f.wiki_textarea(:description)
55
      assert_include '<label for="issue_description">Description</label>', output
56
      assert_include '<div class="wiki-editor" data-controller="common-mark-toolbar"', output
57
      assert_include 'test description', output
58
    end
59
  end
50 60
end
(2-2/2)