Project

General

Profile

Patch #43259 » 0001-jstoolbar-to-stimulus.patch

Takashi Kato, 2026-03-01 03:14

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 BrowserHelper
32 33

  
33 34
  extend Forwardable
34
  def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
35
  def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter, :wikitoolbar
35 36

  
36 37
  # Return true if user is authorized for controller/action, otherwise false
37 38
  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

  
12
  def modifier
13
    mac_os? ? 'meta' : 'ctrl'
14
  end
15
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
import { post } from "@rails/request.js"
3

  
4
export default class extends Controller {
5

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

  
13
  static lastPreviewed = null;
14

  
15
  connect() {
16
    this.field = this.hasFieldTarget ? this.fieldTarget : document.getElementById(this.element.dataset.fieldId)
17
    if (!this.field) return
18
  }
19

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  
166
  async preview(event) {
167
    event.preventDefault()
168
    const previewTab = event.currentTarget
169
    if (previewTab.classList.contains('selected')) return
170

  
171
    const formData = new FormData()
172
    formData.append('text', this.field.value)
173
    const form = this.field.closest('form')
174
    const attachmentInputs = form.querySelectorAll('.attachments_fields input');
175

  
176
    attachmentInputs.forEach(input => {
177
      if (input.name && input.value) {
178
        formData.append(input.name, input.value);
179
      }
180
    });
181

  
182
    try {
183
      const response = await post(this.previewUrlValue, {
184
        body: formData
185
      })
186
      if (response.ok) {
187
        const html = await response.text
188
        this.showPreview(html)
189
      }
190
    } catch (error) {
191
      console.error("Preview failed", error)
192
    }
193
  }
194

  
195
  edit(event) {
196
    event.preventDefault()
197
    const editTab = event.currentTarget
198
    if (editTab.classList.contains('selected')) return
199

  
200
    this.hidePreview(editTab)
201
  }
202

  
203
  togglePreview(event) {
204
    if (this.constructor.lastPreviewed !== null) {
205
      this.constructor.lastPreviewed.hidePreview()
206
      this.constructor.lastPreviewed = null
207
    }
208
  }
209

  
210
  showPreview(html) {
211
    // Find or create preview div
212
    this.previewPaneTarget.innerHTML = html
213
    this.editPaneTarget.classList.add('hidden')
214
    this.previewPaneTarget.classList.remove('hidden')
215
    this.editTabTarget.classList.remove('selected')
216
    this.previewTabTarget.classList.add('selected')
217
    this.buttonsTarget.classList.add('hidden')
218
    this.constructor.lastPreviewed = this;
219
  }
220

  
221
  hidePreview(editTab) {
222
    this.editPaneTarget.classList.remove('hidden')
223
    this.previewPaneTarget.classList.add('hidden')
224
    this.editTabTarget.classList.add('selected')
225
    this.previewTabTarget.classList.remove('selected')
226
    this.buttonsTarget.classList.remove('hidden')
227
  }
228

  
229
  tableMenu(button) {
230
    if (this.menu) {
231
      this.menu.remove()
232
      this.menu = null
233
      return
234
    }
235

  
236
    const alphabets = "ABCDEFGHIJ".split('')
237
    const menu = document.createElement('table')
238
    menu.className = 'table-generator'
239
    this.menu = menu
240

  
241
    for (let r = 1; r <= 5; r++) {
242
      const row = document.createElement('tr')
243
      for (let c = 1; c <= 10; c++) {
244
        const cell = document.createElement('td')
245
        cell.dataset.row = r
246
        cell.dataset.col = c
247
        cell.title = `${c}×${r}`
248

  
249
        cell.addEventListener('mousedown', (e) => {
250
          e.preventDefault()
251
          this.insertTable(c, r)
252
          this.menu.remove()
253
          this.menu = null
254
        })
255

  
256
        cell.addEventListener('mouseenter', () => {
257
          const cells = menu.querySelectorAll('td')
258
          cells.forEach(el => {
259
            if (parseInt(el.dataset.row) <= r && parseInt(el.dataset.col) <= c) {
260
              el.classList.add('selected-cell')
261
            } else {
262
              el.classList.remove('selected-cell')
263
            }
264
          })
265
        })
266

  
267
        row.appendChild(cell)
268
      }
269
      menu.appendChild(row)
270
    }
271

  
272
    document.body.appendChild(menu)
273

  
274
    // Positioning
275
    const rect = button.getBoundingClientRect()
276
    menu.style.left = `${rect.left + window.scrollX}px`
277
    menu.style.top = `${rect.bottom + window.scrollY}px`
278

  
279
    const closeMenu = (e) => {
280
      if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) {
281
        this.menu.remove()
282
        this.menu = null
283
        document.removeEventListener('mousedown', closeMenu)
284
      }
285
    }
286
    document.addEventListener('mousedown', closeMenu)
287
  }
288

  
289
  insertTable(cols, rows) {
290
    // abstract
291
  }
292

  
293
  precodeMenu(button) {
294
    if (this.menu) {
295
      this.menu.remove()
296
      this.menu = null
297
      return
298
    }
299

  
300
    const menu = document.createElement('ul')
301
    menu.className = 'drdn-items'
302
    menu.style.position = 'absolute'
303
    menu.style.zIndex = '1000'
304
    menu.style.backgroundColor = '#fff'
305
    menu.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)'
306
    menu.style.border = '1px solid #d7d7d7'
307
    menu.style.listStyle = 'none'
308
    menu.style.padding = '5px 0'
309
    menu.style.margin = '0'
310
    menu.style.minWidth = '150px'
311
    menu.style.maxHeight = '300px'
312
    menu.style.overflowY = 'auto'
313

  
314
    this.menu = menu
315

  
316
    this.languagesValue.forEach(lang => {
317
      const item = document.createElement('li')
318
      const link = document.createElement('a')
319
      link.href = '#'
320
      link.textContent = lang
321
      link.style.display = 'block'
322
      link.style.padding = '4px 12px'
323
      link.style.color = '#333'
324
      link.style.textDecoration = 'none'
325
      link.style.fontSize = '0.9em'
326

  
327
      link.addEventListener('mouseenter', () => {
328
        link.style.backgroundColor = '#3e70ad'
329
        link.style.color = '#fff'
330
      })
331
      link.addEventListener('mouseleave', () => {
332
        link.style.backgroundColor = 'transparent'
333
        link.style.color = '#333'
334
      })
335

  
336
      link.addEventListener('mousedown', (e) => {
337
        e.preventDefault()
338
        this.insertPrecode(lang)
339
        this.menu.remove()
340
        this.menu = null
341
      })
342

  
343
      item.appendChild(link)
344
      menu.appendChild(item)
345
    })
346

  
347
    document.body.appendChild(menu)
348

  
349
    // Positioning
350
    const rect = button.getBoundingClientRect()
351
    menu.style.left = `${rect.left + window.scrollX}px`
352
    menu.style.top = `${rect.bottom + window.scrollY}px`
353

  
354
    const closeMenu = (e) => {
355
      if (this.menu && !this.menu.contains(e.target) && e.target !== button && !button.contains(e.target)) {
356
        this.menu.remove()
357
        this.menu = null
358
        document.removeEventListener('mousedown', closeMenu)
359
      }
360
    }
361
    document.addEventListener('mousedown', closeMenu)
362
  }
363

  
364
  insertPrecode(lang) {
365
    // abstract
366
  }
367
}
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',
34
            :data => {
35
                :auto_complete => true
36
            }.merge(list_autofill_data_attributes),
33
      <%= wikitoolbar preview_url: preview_issue_path(:project_id => @project, :issue_id => @issue) do |dataset| %>
34
        <%= f.textarea :notes, :cols => 60, :rows => 10,
35
            :data => dataset,
37 36
            :no_label => true %>
38
      <%= wikitoolbar_for 'issue_notes', preview_issue_path(:project_id => @project, :issue_id => @issue) %>
37
      <% end %>
39 38

  
40 39
      <% if @issue.safe_attribute? 'private_notes' %>
41 40
      <%= 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
    <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit), :class => 'wiki-edit',
36
    <%= wikitoolbar preview_url: preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) do |dataset| %>
37
    <%= f.textarea :description, :cols => 60, :accesskey => accesskey(:edit),
37 38
                   :rows => [[10, @issue.description.to_s.length / 50].max, 20].min,
38
                   :data => {
39
                       :auto_complete => true
40
                   }.merge(list_autofill_data_attributes),
39
                   :data => dataset,
41 40
                   :no_label => true %>
41
    <% end %>
42 42
  <% end %>
43 43
  <%= 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) %>
44
</div>
46 45
<% end %>
47 46

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

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

  
58 56
<% 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
889 889
  label_index_by_date: Index by date
890 890
  label_current_version: Current version
891 891
  label_preview: Preview
892
  label_wiki_toolbar_strong: Strong
893
  label_wiki_toolbar_italic: Italic
894
  label_wiki_toolbar_underline: Underline
895
  label_wiki_toolbar_deleted: Deleted
896
  label_wiki_toolbar_code: Inline Code
897
  label_wiki_toolbar_heading_1: Heading 1
898
  label_wiki_toolbar_heading_2: Heading 2
899
  label_wiki_toolbar_heading_3: Heading 3
900
  label_wiki_toolbar_unordered_list: Unordered list
901
  label_wiki_toolbar_ordered_list: Ordered list
902
  label_wiki_toolbar_task_list: Task list
903
  label_wiki_toolbar_quote: Quote
904
  label_wiki_toolbar_unquote: Remove quote
905
  label_wiki_toolbar_table: Table
906
  label_wiki_toolbar_preformatted_text: Preformatted text
907
  label_wiki_toolbar_preformatted_code: Highlighted code
908
  label_wiki_toolbar_wiki_link: Link to a Wiki page
909
  label_wiki_toolbar_image: Image
910
  label_wiki_toolbar_help: Help
892 911
  label_feed_plural: Feeds
893 912
  label_changes_details: Details of all changes
894 913
  label_issue_tracking: Issue tracking
lib/redmine/wiki_formatting.rb
142 142
      end
143 143

  
144 144
      module Helper
145
        def wikitoolbar(field_id, preview_url = preview_text_path, &)
146
        end
147

  
145 148
        def wikitoolbar_for(field_id, preview_url = preview_text_path)
146 149
        end
147 150

  
lib/redmine/wiki_formatting/common_mark/helper.rb
21 21
  module WikiFormatting
22 22
    module CommonMark
23 23
      module Helper
24
        def wikitoolbar(field_id: nil, preview_url: preview_text_path, auto_complete: true, list_autofill: true, &)
25
          @wikitoolbar ||= WikiToolbar.new(self)
26
          @wikitoolbar.render(field_id: field_id, preview_url: preview_url, auto_complete: auto_complete, list_autofill: list_autofill, &)
27
        end
28

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

  
......
59 64
          end
60 65
        end
61 66
      end
67

  
68
      class WikiToolbar < Redmine::WikiToolbar
69
        def controller_name
70
          'common-mark-toolbar'
71
        end
72

  
73
        def buttons
74
          [
75
            { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' },
76
            { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' },
77
            { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' },
78
            { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' },
79
            { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' },
80
            { class: 'jstSpacer' },
81
            { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' },
82
            { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' },
83
            { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' },
84
            { class: 'jstSpacer' },
85
            { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' },
86
            { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' },
87
            { label: :label_wiki_toolbar_task_list, class: 'jstb_tl', icon: 'list-check', action: 'taskList' },
88
            { class: 'jstSpacer' },
89
            { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' },
90
            { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' },
91
            { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' },
92
            { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' },
93
            { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' },
94
            { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' },
95
            { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' },
96
            { class: 'spacer' },
97
            { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' }
98
          ]
99
        end
100
      end
62 101
    end
63 102
  end
64 103
end
lib/redmine/wiki_formatting/textile/helper.rb
21 21
  module WikiFormatting
22 22
    module Textile
23 23
      module Helper
24
        def wikitoolbar(field_id: nil, preview_url: preview_text_path, auto_complete: true, list_autofill: true, &)
25
          @wikitoolbar ||= WikiToolbar.new(self)
26
          @wikitoolbar.render(field_id: field_id, preview_url: preview_url, auto_complete: auto_complete, list_autofill: list_autofill, &)
27
        end
28

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

  
......
57 62
          end
58 63
        end
59 64
      end
65

  
66
      class WikiToolbar < Redmine::WikiToolbar
67
        def controller_name
68
          'textile-toolbar'
69
        end
70

  
71
        def buttons
72
          [
73
            { label: :label_wiki_toolbar_strong, class: 'jstb_strong', icon: 'bold', action: 'strong', shortcut: 'b' },
74
            { label: :label_wiki_toolbar_italic, class: 'jstb_em', icon: 'italic', action: 'italic', shortcut: 'i' },
75
            { label: :label_wiki_toolbar_underline, class: 'jstb_ins', icon: 'underline', action: 'underline', shortcut: 'u' },
76
            { label: :label_wiki_toolbar_deleted, class: 'jstb_del', icon: 'strikethrough', action: 'deleted' },
77
            { label: :label_wiki_toolbar_code, class: 'jstb_code', icon: 'inline-code', action: 'inlineCode' },
78
            { class: 'spacer' },
79
            { label: :label_wiki_toolbar_heading_1, class: 'jstb_h1', icon: 'h1', action: 'h1' },
80
            { label: :label_wiki_toolbar_heading_2, class: 'jstb_h2', icon: 'h2', action: 'h2' },
81
            { label: :label_wiki_toolbar_heading_3, class: 'jstb_h3', icon: 'h3', action: 'h3' },
82
            { class: 'spacer' },
83
            { label: :label_wiki_toolbar_unordered_list, class: 'jstb_ul', icon: 'list', action: 'unorderedList' },
84
            { label: :label_wiki_toolbar_ordered_list, class: 'jstb_ol', icon: 'list-numbers', action: 'orderedList' },
85
            { class: 'spacer' },
86
            { label: :label_wiki_toolbar_quote, class: 'jstb_bq', icon: 'indent-increase', action: 'quote' },
87
            { label: :label_wiki_toolbar_unquote, class: 'jstb_unbq', icon: 'indent-decrease', action: 'unquote' },
88
            { label: :label_wiki_toolbar_table, class: 'jstb_table', icon: 'table', action: 'table' },
89
            { label: :label_wiki_toolbar_preformatted_text, class: 'jstb_pre', icon: 'pre', action: 'pre' },
90
            { label: :label_wiki_toolbar_preformatted_code, class: 'jstb_precode', icon: 'changeset', action: 'precode' },
91
            { label: :label_wiki_toolbar_wiki_link, class: 'jstb_wiki_link', icon: 'wiki-link', action: 'wikiLink' },
92
            { label: :label_wiki_toolbar_image, class: 'jstb_image', icon: 'image', action: 'image' },
93
            { class: 'spacer' },
94
            { label: :label_wiki_toolbar_help, class: 'jstb_help', icon: 'help', action: 'help' }
95
          ]
96
        end
97
      end
60 98
    end
61 99
  end
62 100
end
lib/redmine/wiki_toolbar.rb
1
#attributes frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
module Redmine
21
  class WikiToolbar
22
    extend Forwardable
23
    def_delegators :@view_context, :tag, :concat, :help_wiki_syntax_path, :l, :safe_join, :sprite_icon, :modifier_key, :modifier
24

  
25
    def initialize(view_context)
26
      @view_context = view_context
27
    end
28

  
29
    def controller_name
30
    end
31

  
32
    def buttons
33
    end
34

  
35
    def render(field_id:, preview_url:, auto_complete:, list_autofill:)
36
      tag.div(class: 'wiki-editor',
37
              data: {
38
                controller: controller_name,
39
                field_id: field_id,
40
                "#{controller_name}-preview-url-value": preview_url,
41
                "#{controller_name}-help-url-value": help_wiki_syntax_path,
42
                "#{controller_name}-languages-value": current_language
43
              }) do
44
        concat(
45
          tag.div(class: 'wiki-editor-header') do
46
            concat(
47
              tag.div(class: 'wiki-editor-tabs') do
48
                concat tag.button(l(:button_edit), type: 'button', class: 'wiki-editor-tab selected', data: { action: "#{controller_name}#edit", "#{controller_name}-target": "editTab" })
49
                concat tag.button(l(:label_preview), type: 'button', class: 'wiki-editor-tab', data: { action: "#{controller_name}#preview", "#{controller_name}-target": "previewTab" })
50
              end
51
            )
52
            concat(
53
              tag.div(class: 'wiki-editor-buttons', data: { "#{controller_name}-target": "buttons" }) do
54
                render_buttons
55
              end
56
            )
57
          end
58
        )
59

  
60
        concat(
61
          tag.div(class: 'wiki-editor-pane wiki-editor-edit', data: { "#{controller_name}-target": "editPane" }) do
62
            yield(attributes(field_id, auto_complete, list_autofill))
63
          end
64
        )
65

  
66
        concat(
67
          tag.div(class: 'wiki-editor-pane wiki-editor-preview wiki hidden', data: { "#{controller_name}-target": "previewPane" })
68
        )
69
      end
70
    end
71

  
72
    private
73

  
74
    def attributes(field_id, auto_complete, list_autofill)
75
      controllers = []
76
      actions = ["keydown.#{modifier}+b->#{controller_name}#strong:prevent",
77
                 "keydown.#{modifier}+i->#{controller_name}#italic:prevent",
78
                 "keydown.#{modifier}+u->#{controller_name}#underline:prevent",
79
                 "keydown.#{modifier}+shift+p@window->#{controller_name}#togglePreview:prevent",
80
                 "keydown.#{modifier}+shift+p->#{controller_name}#preview:prevent"]
81
      data = {}
82
      if field_id.nil?
83
        data["#{controller_name}_target"] = 'field'
84
      end
85

  
86
      if auto_complete
87
        data[:auto_complete] = true
88
      end
89

  
90
      if Setting.text_formatting.present? && list_autofill
91
        controllers << 'list-autofill'
92
        actions << 'beforeinput->list-autofill#handleBeforeInput'
93
        data[:list_autofill_text_formatting_param] = Setting.text_formatting
94
      end
95
      data.merge!({controller: controllers.join(' '), action: actions.join(' ')})
96
    end
97

  
98
    def render_buttons
99
      @rendered_buttons ||= safe_join(
100
        buttons.map do |button|
101
          if button[:class] == 'jstSpacer' || button[:class] == 'spacer'
102
            tag.span(class: 'jstSpacer')
103
          else
104
            title = l(button[:label])
105
            title += " (#{modifier_key}+#{button[:shortcut].upcase})" if button[:shortcut]
106

  
107
            tag.button(
108
              button_content(button, title),
109
              type: 'button',
110
              class: button[:class],
111
              title: title,
112
              data: { action: "#{controller_name}##{button[:action]}" }
113
            )
114
          end
115
        end)
116
    end
117

  
118
    def button_content(button, title)
119
      if button[:icon] == 'pre'
120
        tag.svg(class: 's16 icon-svg', aria: { hidden: true }, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round',
121
                'stroke-linejoin': 'round') do
122
          concat tag.rect(x: '3', y: '5', width: '18', height: '14', rx: '2')
123
          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')
124
        end
125
      else
126
        sprite_icon(button[:icon], title, icon_only: true, size: 16)
127
      end
128
    end
129

  
130
    def current_language
131
      @current_language ||= (User.current && User.current.pref.toolbar_language_options || UserPreference::DEFAULT_TOOLBAR_LANGUAGE_OPTIONS).split(',')
132
    end
133
  end
134
end
(3-3/3)