Project

General

Profile

Patch #43257 » 0001-Remove-unnecessary-files-for-quote-reply-feature.patch

Katsuya HIDAKA, 2025-09-27 13:35

View differences:

app/assets/javascripts/quote_reply.js
1
import { Controller } from '@hotwired/stimulus'
2
import TurndownService from 'turndown'
3
import { post } from '@rails/request.js'
4

  
5
class QuoteExtractor {
6
  static extract(targetElement) {
7
    return new QuoteExtractor(targetElement).extract();
8
  }
9

  
10
  constructor(targetElement) {
11
    this.targetElement = targetElement;
12
    this.selection = window.getSelection();
13
  }
14

  
15
  extract() {
16
    const range = this.retriveSelectedRange();
17

  
18
    if (!range) {
19
      return null;
20
    }
21

  
22
    if (!this.targetElement.contains(range.startContainer)) {
23
      range.setStartBefore(this.targetElement);
24
    }
25
    if (!this.targetElement.contains(range.endContainer)) {
26
      range.setEndAfter(this.targetElement);
27
    }
28

  
29
    return range;
30
  }
31

  
32
  retriveSelectedRange() {
33
    if (!this.isSelected) {
34
      return null;
35
    }
36

  
37
    // Retrive the first range that intersects with the target element.
38
    // NOTE: Firefox allows to select multiple ranges in the document.
39
    for (let i = 0; i < this.selection.rangeCount; i++) {
40
      let range = this.selection.getRangeAt(i);
41
      if (range.intersectsNode(this.targetElement)) {
42
        return range;
43
      }
44
    }
45
    return null;
46
  }
47

  
48
  get isSelected() {
49
    return this.selection.containsNode(this.targetElement, true);
50
  }
51
}
52

  
53
class QuoteTextFormatter {
54
  format(selectedRange) {
55
    if (!selectedRange) {
56
      return null;
57
    }
58

  
59
    const fragment = document.createElement('div');
60
    fragment.appendChild(selectedRange.cloneContents());
61

  
62
    // Remove all unnecessary anchor elements
63
    fragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
64

  
65
    const html = this.adjustLineBreaks(fragment.innerHTML);
66

  
67
    const result = document.createElement('div');
68
    result.innerHTML = html;
69

  
70
    // Replace continuous line breaks with a single line break and remove tab characters
71
    return result.textContent
72
      .trim()
73
      .replace(/\t/g, '')
74
      .replace(/\n+/g, "\n");
75
  }
76

  
77
  adjustLineBreaks(html) {
78
    return html
79
      .replace(/<\/(h1|h2|h3|h4|div|p|li|tr)>/g, "\n</$1>")
80
      .replace(/<br>/g, "\n")
81
  }
82
}
83

  
84
class QuoteCommonMarkFormatter {
85
  format(selectedRange) {
86
    if (!selectedRange) {
87
      return null;
88
    }
89

  
90
    const htmlFragment = this.extractHtmlFragmentFrom(selectedRange);
91
    const preparedHtml = this.prepareHtml(htmlFragment);
92

  
93
    return this.convertHtmlToCommonMark(preparedHtml);
94
  }
95

  
96
  extractHtmlFragmentFrom(range) {
97
    const fragment = document.createElement('div');
98
    const ancestorNodeName = range.commonAncestorContainer.nodeName;
99

  
100
    if (ancestorNodeName == 'CODE' || ancestorNodeName == '#text') {
101
      fragment.appendChild(this.wrapPreCode(range));
102
    } else {
103
      fragment.appendChild(range.cloneContents());
104
    }
105

  
106
    return fragment;
107
  }
108

  
109
  // When only the content within the `<code>` element is selected,
110
  // the HTML within the selection range does not include the `<pre><code>` element itself.
111
  // To create a complete code block, wrap the selected content with the `<pre><code>` tags.
112
  //
113
  // selected contentes => <pre><code class="ruby">selected contents</code></pre>
114
  wrapPreCode(range) {
115
    const rangeAncestor = range.commonAncestorContainer;
116

  
117
    let codeElement = null;
118

  
119
    if (rangeAncestor.nodeName == 'CODE') {
120
      codeElement = rangeAncestor;
121
    } else {
122
      codeElement = rangeAncestor.parentElement.closest('code');
123
    }
124

  
125
    if (!codeElement) {
126
      return range.cloneContents();
127
    }
128

  
129
    const pre = document.createElement('pre');
130
    const code = codeElement.cloneNode(false);
131

  
132
    code.appendChild(range.cloneContents());
133
    pre.appendChild(code);
134

  
135
    return pre;
136
  }
137

  
138
  convertHtmlToCommonMark(html) {
139
    const turndownService = new TurndownService({
140
      codeBlockStyle: 'fenced',
141
      headingStyle: 'atx'
142
    });
143

  
144
    turndownService.addRule('del', {
145
      filter: ['del'],
146
      replacement: content => `~~${content}~~`
147
    });
148

  
149
    turndownService.addRule('checkList', {
150
      filter: node => {
151
        return node.type === 'checkbox' && node.parentNode.nodeName === 'LI';
152
      },
153
      replacement: (content, node) => {
154
        return node.checked ? '[x]' : '[ ]';
155
      }
156
    });
157

  
158
    // Table does not maintain its original format,
159
    // and the text within the table is displayed as it is
160
    //
161
    // | A | B | C |
162
    // |---|---|---|
163
    // | 1 | 2 | 3 |
164
    // =>
165
    // A B C
166
    // 1 2 3
167
    turndownService.addRule('table', {
168
      filter: ['td', 'th'],
169
      replacement: (content, node) => {
170
        const separator = node.parentElement.lastElementChild === node ? '' : ' ';
171
        return content + separator;
172
      }
173
    });
174
    turndownService.addRule('tableHeading', {
175
      filter: ['thead', 'tbody', 'tfoot', 'tr'],
176
      replacement: (content, _node) => content
177
    });
178
    turndownService.addRule('tableRow', {
179
      filter: ['tr'],
180
      replacement: (content, _node) => {
181
        return content + '\n'
182
      }
183
    });
184

  
185
    return turndownService.turndown(html);
186
  }
187

  
188
  prepareHtml(htmlFragment) {
189
    // Remove all anchor elements.
190
    // <h1>Title1<a href="#Title" class="wiki-anchor">¶</a></h1> => <h1>Title1</h1>
191
    htmlFragment.querySelectorAll('a.wiki-anchor').forEach(e => e.remove());
192

  
193
    // Convert code highlight blocks to CommonMark format code blocks.
194
    // <code class="ruby" data-language="ruby"> => <code class="language-ruby" data-language="ruby">
195
    htmlFragment.querySelectorAll('code[data-language]').forEach(e => {
196
      e.classList.replace(e.dataset['language'], 'language-' + e.dataset['language'])
197
    });
198

  
199
    return htmlFragment.innerHTML;
200
  }
201
}
202

  
203
export default class extends Controller {
204
  static targets = [ 'content' ];
205

  
206
  quote(event) {
207
    const { url, textFormatting } = event.params;
208
    const selectedRange = QuoteExtractor.extract(this.contentTarget);
209

  
210
    let formatter;
211

  
212
    if (textFormatting === 'common_mark') {
213
      formatter = new QuoteCommonMarkFormatter();
214
    } else {
215
      formatter = new QuoteTextFormatter();
216
    }
217

  
218
    post(url, {
219
      body: JSON.stringify({ quote: formatter.format(selectedRange) }),
220
      contentType: 'application/json',
221
      responseKind: 'script'
222
    });
223
  }
224
}
    (1-1/1)