Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
81.98% |
91 / 111 |
|
66.67% |
8 / 12 |
CRAP | |
0.00% |
0 / 1 |
HtmlEditableTextComponent | |
81.98% |
91 / 111 |
|
66.67% |
8 / 12 |
27.37 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getHtml | |
86.96% |
20 / 23 |
|
0.00% |
0 / 1 |
4.04 | |||
buildResetElements | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
buildTextEditor | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
buildLineFeedField | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
buildEditButton | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
buildSaveButton | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
buildResetButton | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
buildExpandButton | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
buildCollapseButton | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
countExtraLineFeeds | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
rowsForText | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
4.01 |
1 | <?php |
2 | |
3 | namespace TwoColConflict\Html; |
4 | |
5 | use Language; |
6 | use MediaWiki\Html\Html; |
7 | use MessageLocalizer; |
8 | use OOUI\ButtonWidget; |
9 | |
10 | /** |
11 | * @license GPL-2.0-or-later |
12 | */ |
13 | class HtmlEditableTextComponent { |
14 | |
15 | private MessageLocalizer $messageLocalizer; |
16 | private Language $language; |
17 | private ?string $editFontOption; |
18 | |
19 | /** |
20 | * @param MessageLocalizer $messageLocalizer |
21 | * @param Language $language |
22 | * @param string|null $editFontOption Supported values are "monospace" (default), "sans-serif", |
23 | * and "serif" |
24 | */ |
25 | public function __construct( |
26 | MessageLocalizer $messageLocalizer, |
27 | Language $language, |
28 | string $editFontOption = null |
29 | ) { |
30 | $this->messageLocalizer = $messageLocalizer; |
31 | $this->language = $language; |
32 | $this->editFontOption = $editFontOption; |
33 | } |
34 | |
35 | /** |
36 | * @param string $diffHtml |
37 | * @param string|null $text |
38 | * @param int $rowNum |
39 | * @param string $changeType |
40 | * @param bool $isDisabled |
41 | * |
42 | * @return string |
43 | */ |
44 | public function getHtml( |
45 | string $diffHtml, |
46 | ?string $text, |
47 | int $rowNum, |
48 | string $changeType, |
49 | bool $isDisabled = false |
50 | ): string { |
51 | $diffHtml = trim( $diffHtml, "\r\n\u{00A0}" ); |
52 | $editorText = trim( (string)$text, "\r\n" ); |
53 | // This duplicates what \MediaWiki\EditPage\EditPage::addNewLineAtEnd() does |
54 | if ( $editorText !== '' ) { |
55 | $editorText .= "\n"; |
56 | } |
57 | $classes = [ 'mw-twocolconflict-split-editable' ]; |
58 | |
59 | $innerHtml = Html::rawElement( |
60 | 'span', |
61 | [ 'class' => 'mw-twocolconflict-split-difftext' ], |
62 | $diffHtml |
63 | ); |
64 | $innerHtml .= Html::element( 'div', [ 'class' => 'mw-twocolconflict-split-fade' ] ); |
65 | $innerHtml .= $this->buildTextEditor( $editorText, $rowNum, $changeType, $isDisabled ); |
66 | if ( !$isDisabled ) { |
67 | $innerHtml .= $this->buildEditButton(); |
68 | $innerHtml .= $this->buildSaveButton(); |
69 | $innerHtml .= $this->buildResetButton(); |
70 | } |
71 | |
72 | if ( $changeType === 'copy' ) { |
73 | $innerHtml .= $this->buildExpandButton(); |
74 | $innerHtml .= $this->buildCollapseButton(); |
75 | $classes[] = 'mw-twocolconflict-split-collapsed'; |
76 | } |
77 | |
78 | $innerHtml .= $this->buildResetElements( $diffHtml ); |
79 | $innerHtml .= $this->buildLineFeedField( $text, $rowNum, $changeType ); |
80 | |
81 | return Html::rawElement( 'div', [ 'class' => $classes ], $innerHtml ); |
82 | } |
83 | |
84 | private function buildResetElements( string $diffHtml ): string { |
85 | return Html::rawElement( |
86 | 'span', [ 'class' => 'mw-twocolconflict-split-reset-diff-text' ], |
87 | $diffHtml |
88 | ); |
89 | } |
90 | |
91 | private function buildTextEditor( |
92 | string $editorText, |
93 | int $rowNum, |
94 | string $changeType, |
95 | bool $isDisabled |
96 | ): string { |
97 | $attributes = [ |
98 | 'class' => 'mw-editfont-' . ( $this->editFontOption ?: 'monospace' ) . ' mw-twocolconflict-split-editor', |
99 | 'lang' => $this->language->getHtmlCode(), |
100 | 'dir' => $this->language->getDir(), |
101 | 'rows' => $this->rowsForText( $editorText ), |
102 | 'autocomplete' => 'off', |
103 | 'tabindex' => '1', |
104 | ]; |
105 | if ( $isDisabled ) { |
106 | $attributes[] = 'readonly'; |
107 | } |
108 | |
109 | /** |
110 | * "If the next token is a U+000A LINE FEED (LF) character token, then ignore that token and |
111 | * move on to the next one. (Newlines at the start of textarea elements are ignored as an |
112 | * authoring convenience.)" |
113 | * @see https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody |
114 | * Html::textarea() respects this, but Html::element() doesn't. |
115 | */ |
116 | return Html::textarea( |
117 | 'mw-twocolconflict-split-content[' . $rowNum . '][' . $changeType . ']', |
118 | $editorText, |
119 | $attributes |
120 | ); |
121 | } |
122 | |
123 | private function buildLineFeedField( ?string $text, int $rowNum, string $changeType ): string { |
124 | $counts = $this->countExtraLineFeeds( $text ); |
125 | |
126 | if ( $counts === '0' ) { |
127 | // Reduce the stuff we transfer back and forth if it's the default value anyway |
128 | return ''; |
129 | } |
130 | |
131 | return Html::hidden( "mw-twocolconflict-split-linefeeds[$rowNum][$changeType]", $counts ); |
132 | } |
133 | |
134 | private function buildEditButton() { |
135 | return new ButtonWidget( [ |
136 | 'infusable' => true, |
137 | 'framed' => false, |
138 | 'icon' => 'edit', |
139 | 'title' => $this->messageLocalizer->msg( 'twocolconflict-split-edit-tooltip' )->text(), |
140 | 'classes' => [ 'mw-twocolconflict-split-edit-button' ], |
141 | 'tabIndex' => '1', |
142 | ] ); |
143 | } |
144 | |
145 | private function buildSaveButton() { |
146 | return new ButtonWidget( [ |
147 | 'infusable' => true, |
148 | 'framed' => false, |
149 | 'icon' => 'check', |
150 | 'title' => $this->messageLocalizer->msg( 'twocolconflict-split-save-tooltip' )->text(), |
151 | 'classes' => [ 'mw-twocolconflict-split-save-button' ], |
152 | 'tabIndex' => '1', |
153 | ] ); |
154 | } |
155 | |
156 | private function buildResetButton() { |
157 | return new ButtonWidget( [ |
158 | 'infusable' => true, |
159 | 'framed' => false, |
160 | 'icon' => 'close', |
161 | 'title' => $this->messageLocalizer->msg( 'twocolconflict-split-reset-tooltip' )->text(), |
162 | 'classes' => [ 'mw-twocolconflict-split-reset-button' ], |
163 | 'tabIndex' => '1', |
164 | ] ); |
165 | } |
166 | |
167 | private function buildExpandButton() { |
168 | return new ButtonWidget( [ |
169 | 'infusable' => true, |
170 | 'framed' => false, |
171 | 'icon' => 'expand', |
172 | 'title' => $this->messageLocalizer->msg( 'twocolconflict-split-expand-tooltip' )->text(), |
173 | 'classes' => [ 'mw-twocolconflict-split-expand-button' ], |
174 | 'tabIndex' => '1', |
175 | ] ); |
176 | } |
177 | |
178 | private function buildCollapseButton() { |
179 | return new ButtonWidget( [ |
180 | 'infusable' => true, |
181 | 'framed' => false, |
182 | 'icon' => 'collapse', |
183 | 'title' => $this->messageLocalizer->msg( 'twocolconflict-split-collapse-tooltip' )->text(), |
184 | 'classes' => [ 'mw-twocolconflict-split-collapse-button' ], |
185 | 'tabIndex' => '1', |
186 | ] ); |
187 | } |
188 | |
189 | private function countExtraLineFeeds( ?string $text ): string { |
190 | if ( $text === null ) { |
191 | return '0'; |
192 | } |
193 | |
194 | $endOfText = strlen( rtrim( $text, "\r\n" ) ); |
195 | $after = substr_count( $text, "\n", $endOfText ); |
196 | |
197 | // Detect text that contains nothing but linebreaks, i.e. appears empty |
198 | if ( $endOfText === 0 ) { |
199 | // The merger will drop sections that have been emptied by the user, except they are |
200 | // marked as "was empty before". |
201 | return "$after,was-empty"; |
202 | } |
203 | |
204 | $before = substr_count( $text, "\n", 0, strspn( $text, "\r\n", 0, $endOfText ) ); |
205 | if ( $before ) { |
206 | // "Before" and "after" are intentionally flipped, because "before" is very rare |
207 | return "$after,$before"; |
208 | } else { |
209 | return (string)$after; |
210 | } |
211 | } |
212 | |
213 | /** |
214 | * Estimate the appropriate size textbox to use for a given text. |
215 | * |
216 | * @param string $text Contents of the textbox |
217 | * |
218 | * @return int Suggested number of rows |
219 | */ |
220 | private function rowsForText( string $text ): int { |
221 | $thresholds = [ |
222 | 80 * 10 => 18, |
223 | 80 * 4 => 6, |
224 | 0 => 3, |
225 | ]; |
226 | $numChars = function_exists( 'grapheme_strlen' ) |
227 | ? grapheme_strlen( $text ) : mb_strlen( $text ); |
228 | $upperLimit = min( substr_count( $text, "\n" ) + 1, 2 * 18 ); |
229 | foreach ( $thresholds as $minChars => $rows ) { |
230 | if ( $numChars >= $minChars ) { |
231 | return max( $rows, $upperLimit ); |
232 | } |
233 | } |
234 | // Should be unreachable. |
235 | return $upperLimit; |
236 | } |
237 | |
238 | } |