Skip to content

Commit e64f4f9

Browse files
authored
Merge pull request #10 from github/preserve-markup
Quote markdown tweaks
2 parents 8d4ebd0 + 8b7ccc0 commit e64f4f9

5 files changed

Lines changed: 99 additions & 120 deletions

File tree

README.md

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Quote Markdown selection
1+
# Quote selection
22

3-
Install a shortcut `r` to append selected text to a `<textarea>` as a Markdown quote.
3+
Install a keyboard shortcut <kbd>r</kbd> to append selected text to a `<textarea>` as a Markdown quote.
44

55
## Installation
66

@@ -10,53 +10,36 @@ $ npm install @github/quote-selection
1010

1111
## Usage
1212

13-
### HTML
14-
1513
```html
16-
<div data-quote-region>
14+
<div class="my-quote-region">
1715
<p>Text to quote</p>
1816
<textarea></textarea>
1917
</div>
2018
```
2119

22-
#### Quote as Markdown
23-
24-
An optional feature to translate quoted content into Markdown format is available via the `data-quote-markdown` attribute:
20+
```js
21+
import {install} from '@github/quote-selection'
2522

26-
```html
27-
<div data-quote-region data-quote-markdown=".comment-body">
28-
<div class="comment-body">
29-
<h2><strong>Formatted</strong> text <em>to quote</em></h2>
30-
<p>
31-
Preserves <code>inline code</code> and
32-
<a href="https://guides.github.com/features/mastering-markdown/">other features</a>.
33-
</p>
34-
</div>
35-
<div class="comment-body">
36-
<p>Some other text</p>
37-
</div>
38-
<textarea></textarea>
39-
</div>
23+
install(document.querySelector('.my-quote-region'))
4024
```
4125

42-
When selected, the text within the first `.comment-body` element will get quoted as:
26+
This sets up a keyboard event handler so that selecting any text within `.my-quote-region` and pressing <kbd>r</kbd> appends the quoted representation of the selected text into the first applicable `<textarea>` element.
4327

44-
```
45-
> ## **Formatted** text _to quote_
46-
>
47-
> Preserves `inline code` and [other features](https://guides.github.com/features/mastering-markdown/).
48-
```
49-
50-
### JS
28+
### Preserving Markdown syntax
5129

5230
```js
53-
import {install} from '@github/quote-selection'
54-
install(document.querySelector('[data-quote-region]'))
31+
install(element, {
32+
quoteMarkdown: true,
33+
scopeSelector: '.comment-body'
34+
})
5535
```
5636

37+
The optional `scopeSelector` parameter ensures that even if the user selection bleeds outside of the scoped element, the quoted portion will always be contained inside the scope. This is useful to avoid accidentally quoting parts of the UI that might be interspersed between quotable content.
38+
5739
## Events
5840

59-
A `quote-selection` event is fired on the quote region before text is appended to a textarea. Listen to the event to prepare the textarea or manipulate the selection text.
41+
* `quote-selection-markdown` (bubbles: true, cancelable: false) - fired on the quote region to optionally inject custom syntax into the `fragment` element in `quoteMarkdown: true` mode
42+
* `quote-selection` (bubbles: true, cancelable: true) - fired on the quote region before text is appended to a textarea
6043

6144
For example, reveal a textarea so it can be found:
6245

markdown-parsing.js

Lines changed: 21 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
/* @flow */
22

3-
function matches(el: Element, ...names: Array<string>): boolean {
4-
return names.some(name => el.classList.contains(name))
5-
}
6-
73
function indexInList(li: Element): number {
84
if (li.parentNode === null || !(li.parentNode instanceof HTMLElement)) throw new Error()
95

@@ -34,10 +30,6 @@ function isCheckbox(node: Node): boolean {
3430
return node.nodeName === 'INPUT' && node instanceof HTMLInputElement && node.type === 'checkbox'
3531
}
3632

37-
function isHighlightContainer(el: HTMLElement): boolean {
38-
return el.nodeName === 'DIV' && el.classList.contains('highlight')
39-
}
40-
4133
let listIndexOffset = 0
4234

4335
function nestedListExclusive(li: Element): boolean {
@@ -73,31 +65,23 @@ const filters: {[key: string]: (HTMLElement) => string | HTMLElement} = {
7365
const text = el.textContent
7466

7567
if (el.parentNode && el.parentNode.nodeName === 'PRE') {
76-
el.textContent = `\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\``
68+
el.textContent = `\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n`
7769
return el
7870
}
7971
if (text.indexOf('`') >= 0) {
8072
return `\`\` ${text} \`\``
8173
}
8274
return `\`${text}\``
8375
},
84-
PRE(el) {
85-
const parent = el.parentNode
86-
if (parent instanceof HTMLElement && isHighlightContainer(parent)) {
87-
const match = parent.className.match(/highlight-source-(\S+)/)
88-
const flavor = match ? match[1] : ''
89-
const text = el.textContent.replace(/\n+$/, '')
90-
el.textContent = `\`\`\`${flavor}\n${text}\n\`\`\``
91-
el.append('\n\n')
92-
}
93-
return el
94-
},
9576
STRONG(el) {
9677
return `**${el.textContent}**`
9778
},
9879
EM(el) {
9980
return `_${el.textContent}_`
10081
},
82+
DEL(el) {
83+
return `~${el.textContent}~`
84+
},
10185
BLOCKQUOTE(el) {
10286
const text = el.textContent.trim().replace(/^/gm, '> ')
10387
const pre = document.createElement('pre')
@@ -108,11 +92,7 @@ const filters: {[key: string]: (HTMLElement) => string | HTMLElement} = {
10892
const text = el.textContent
10993
const href = el.getAttribute('href')
11094

111-
if (matches(el, 'user-mention', 'team-mention')) {
112-
return text
113-
} else if (matches(el, 'issue-link') && /^#\d+$/.test(text)) {
114-
return text
115-
} else if (/^https?:/.test(text) && text === href) {
95+
if (/^https?:/.test(text) && text === href) {
11696
return text
11797
} else {
11898
if (href) {
@@ -124,23 +104,17 @@ const filters: {[key: string]: (HTMLElement) => string | HTMLElement} = {
124104
},
125105
IMG(el) {
126106
const alt = el.getAttribute('alt') || ''
107+
const src = el.getAttribute('src')
108+
if (!src) throw new Error()
127109

128-
if (alt && matches(el, 'emoji')) {
129-
return alt
130-
} else {
131-
const src = el.getAttribute('src')
132-
if (!src) throw new Error()
133-
134-
const widthAttr = el.hasAttribute('width') ? ` width="${escapeAttribute(el.getAttribute('width') || '')}"` : ''
135-
const heightAttr = el.hasAttribute('height')
136-
? ` height="${escapeAttribute(el.getAttribute('height') || '')}"`
137-
: ''
110+
const widthAttr = el.hasAttribute('width') ? ` width="${escapeAttribute(el.getAttribute('width') || '')}"` : ''
111+
const heightAttr = el.hasAttribute('height') ? ` height="${escapeAttribute(el.getAttribute('height') || '')}"` : ''
138112

139-
if (widthAttr || heightAttr) {
140-
return `<img alt="${escapeAttribute(alt)}"${widthAttr}${heightAttr} src="${escapeAttribute(src)}">`
141-
} else {
142-
return `![${alt}](${src})`
143-
}
113+
if (widthAttr || heightAttr) {
114+
// eslint-disable-next-line github/unescaped-html-literal
115+
return `<img alt="${escapeAttribute(alt)}"${widthAttr}${heightAttr} src="${escapeAttribute(src)}">`
116+
} else {
117+
return `![${alt}](${src})`
144118
}
145119
},
146120
LI(el) {
@@ -187,12 +161,7 @@ for (let level = 2; level <= 6; ++level) {
187161
filters[`H${level}`] = filters.H1
188162
}
189163

190-
// Public: Iterate over all elements within a node that match one of the filters. The
191-
// iteration is done in reverse as a way of processing deepest matches first.
192-
function fragmentToMarkdown(
193-
root: DocumentFragment,
194-
fn: (node: HTMLElement, content: string | HTMLElement) => void
195-
): void {
164+
export function insertMarkdownSyntax(root: DocumentFragment): void {
196165
const nodeIterator = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, function(node) {
197166
if (node.nodeName in filters && !skipNode(node) && (hasContent(node) || isCheckbox(node))) {
198167
return NodeFilter.FILTER_ACCEPT
@@ -210,13 +179,15 @@ function fragmentToMarkdown(
210179
node = nodeIterator.nextNode()
211180
}
212181

182+
// process deepest matches first
213183
results.reverse()
184+
214185
for (node of results) {
215-
fn(node, filters[node.nodeName](node))
186+
node.replaceWith(filters[node.nodeName](node))
216187
}
217188
}
218189

219-
export default function rangeToMarkdown(range: Range, selector: string, unwrap: boolean): DocumentFragment {
190+
export function extractFragment(range: Range, selector: string): DocumentFragment {
220191
const startNode = range.startContainer
221192
if (!startNode || !startNode.parentNode || !(startNode.parentNode instanceof HTMLElement)) {
222193
throw new Error('the range must start within an HTMLElement')
@@ -238,18 +209,8 @@ export default function rangeToMarkdown(range: Range, selector: string, unwrap:
238209
if (codeBlock) {
239210
const pre = document.createElement('pre')
240211
pre.appendChild(fragment)
241-
let item = pre
242-
if (!unwrap) {
243-
const pp = codeBlock.parentNode
244-
if (pp instanceof HTMLElement && isHighlightContainer(pp)) {
245-
const div = document.createElement('div')
246-
div.className = pp.className
247-
div.appendChild(item)
248-
item = div
249-
}
250-
}
251212
fragment = document.createDocumentFragment()
252-
fragment.appendChild(item)
213+
fragment.appendChild(pre)
253214
} else if (li && li.parentNode) {
254215
if (li.parentNode.nodeName === 'OL') {
255216
listIndexOffset = indexInList(li)
@@ -266,6 +227,6 @@ export default function rangeToMarkdown(range: Range, selector: string, unwrap:
266227
fragment.appendChild(list)
267228
}
268229
}
269-
fragmentToMarkdown(fragment, (el, newContent) => el.replaceWith(newContent))
230+
270231
return fragment
271232
}

quote-selection.js

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,40 @@
11
/* @flow */
22

3-
import rangeToMarkdown from './markdown-parsing'
3+
import {extractFragment, insertMarkdownSyntax} from './markdown-parsing'
44

5-
const containers = new WeakMap()
5+
const containers: WeakMap<Element, ContainerConfig> = new WeakMap()
66
let installed = 0
77

88
const edgeBrowser = /\bEdge\//.test(navigator.userAgent)
99

10+
type ConfigOptions = {|
11+
quoteMarkdown?: boolean,
12+
scopeSelector?: string
13+
|}
14+
15+
type ContainerConfig = {|
16+
quoteMarkdown: boolean,
17+
scopeSelector: string
18+
|}
19+
1020
type Subscription = {|
1121
unsubscribe: () => void
1222
|}
1323

14-
export function subscribe(container: Element): Subscription {
15-
install(container)
24+
export function subscribe(container: Element, options?: ConfigOptions): Subscription {
25+
install(container, options)
1626
return {
1727
unsubscribe: () => {
1828
uninstall(container)
1929
}
2030
}
2131
}
2232

23-
export function install(container: Element) {
33+
export function install(container: Element, options?: ConfigOptions) {
2434
const firstInstall = installed === 0
2535
installed += containers.has(container) ? 0 : 1
26-
containers.set(container, 1)
36+
const config: ContainerConfig = Object.assign({quoteMarkdown: false, scopeSelector: ''}, options)
37+
containers.set(container, config)
2738
if (firstInstall) {
2839
document.addEventListener('keydown', quoteSelection)
2940
}
@@ -118,11 +129,21 @@ function extractQuote(text: string, range: Range, unwrap: boolean): ?Quote {
118129

119130
const container = findContainer(focusNode)
120131
if (!container) return
132+
const options = containers.get(container)
133+
if (!options) return
121134

122-
const markdownSelector = container.getAttribute('data-quote-markdown')
123-
if (markdownSelector != null && !edgeBrowser) {
135+
if (options.quoteMarkdown && !edgeBrowser) {
124136
try {
125-
selectionText = selectFragment(rangeToMarkdown(range, markdownSelector, unwrap))
137+
const fragment = extractFragment(range, options.scopeSelector)
138+
container.dispatchEvent(
139+
new CustomEvent('quote-selection-markdown', {
140+
bubbles: true,
141+
cancelable: false,
142+
detail: {fragment, range, unwrap}
143+
})
144+
)
145+
insertMarkdownSyntax(fragment)
146+
selectionText = selectFragment(fragment)
126147
.replace(/^\n+/, '')
127148
.replace(/\s+$/, '')
128149
} catch (error) {

quote-selection.js.flow

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
/* @flow strict */
22

3+
type ConfigOptions = {|
4+
quoteMarkdown?: boolean,
5+
scopeSelector?: string
6+
|}
7+
38
interface Subscription {
49
unsubscribe: () => void
510
}
611

712
declare module.exports: {
8-
install(container: Element): void;
13+
install(container: Element, options?: ConfigOptions): void;
914
uninstall(container: Element): void;
10-
subscribe(container: Element): Subscription;
15+
subscribe(container: Element, options?: ConfigOptions): Subscription;
1116
findContainer(el: Element): ?Element;
1217
findTextarea(container: Element): ?HTMLTextAreaElement;
1318
quote(text: string, range: Range): boolean;

0 commit comments

Comments
 (0)