Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.98% covered (warning)
81.98%
91 / 111
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
HtmlEditableTextComponent
81.98% covered (warning)
81.98%
91 / 111
66.67% covered (warning)
66.67%
8 / 12
27.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getHtml
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
4.04
 buildResetElements
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildTextEditor
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 buildLineFeedField
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 buildEditButton
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildSaveButton
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildResetButton
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildExpandButton
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 buildCollapseButton
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 countExtraLineFeeds
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 rowsForText
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2
3namespace TwoColConflict\Html;
4
5use Language;
6use MediaWiki\Html\Html;
7use MessageLocalizer;
8use OOUI\ButtonWidget;
9
10/**
11 * @license GPL-2.0-or-later
12 */
13class 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}