├── .github ├── actions │ └── setup │ │ └── action.yml └── workflows │ ├── nodejs.yml │ ├── publish.yml │ └── release.yml ├── .gitignore ├── .travis.yml ├── CODEOWNERS ├── LICENSE ├── README.md ├── examples └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.ts └── markdown.ts ├── test ├── .eslintrc.json └── test.js ├── tsconfig.json └── vitest.config.js /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup project 2 | description: Sets up the repo code, Node.js, and npm dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Set up Node.js 8 | uses: actions/setup-node@v4 9 | with: 10 | node-version: '24.x' 11 | cache: npm 12 | registry-url: https://registry.npmjs.org 13 | - name: Install npm dependencies 14 | run: npm ci 15 | shell: bash 16 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [22.x, 24.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - name: npm install 30 | run: npm ci 31 | - name: npm build 32 | run: npm run build --if-present 33 | - run: npx playwright install chromium 34 | - name: npm test 35 | run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | id-token: write 10 | 11 | jobs: 12 | publish-npm: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 24 19 | registry-url: https://registry.npmjs.org/ 20 | cache: npm 21 | - run: npm ci 22 | - run: npx playwright install chromium 23 | - run: npm test 24 | - run: npm version ${TAG_NAME} --git-tag-version=false 25 | env: 26 | TAG_NAME: ${{ github.event.release.tag_name }} 27 | - run: npm whoami; npm --ignore-scripts publish --provenance 28 | env: 29 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release and publish 2 | 3 | # Inspired by https://github.com/MylesBorins/node-osc/blob/959b9c83972a67390a351d333b23db3972ca7b46/.github/workflows/bump-version.yml and 4 | # https://github.com/MylesBorins/node-osc/blob/74b563c83736a04c4a37acbff9d7bb1f01a00f57/.github/workflows/create-release.yml 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | description: Semver descriptor for new version ("major", "minor", or "patch") 11 | required: true 12 | 13 | permissions: 14 | contents: write 15 | id-token: write 16 | 17 | jobs: 18 | bump-version: 19 | name: Bump package version 20 | runs-on: ubuntu-latest 21 | outputs: 22 | new-tag: ${{ steps.new-tag.outputs.new-tag }} 23 | steps: 24 | - name: Checkout ref 25 | uses: actions/checkout@v4 26 | - name: Preparation 27 | uses: ./.github/actions/setup 28 | - run: npx playwright install chromium 29 | - name: Perform last-minute tests 30 | run: npm test 31 | - name: Configure Git 32 | run: | 33 | git config user.name "GitHub Actions" 34 | git config user.email "actions@github.com" 35 | - name: Bump package version 36 | run: npm version ${{ github.event.inputs.version }} 37 | - name: Push back to GitHub 38 | run: git push origin main --follow-tags 39 | - name: Set output to new version 40 | id: new-tag 41 | run: | 42 | version=$(jq -r .version < package.json) 43 | echo "new-tag=v$version" >> $GITHUB_OUTPUT 44 | create-release: 45 | name: Create GitHub release 46 | runs-on: ubuntu-latest 47 | needs: bump-version 48 | steps: 49 | - name: Checkout ref 50 | uses: actions/checkout@v4 51 | with: 52 | ref: ${{ needs.bump-version.outputs.new-tag }} 53 | - name: Preparation 54 | uses: ./.github/actions/setup 55 | - name: Create release 56 | uses: actions/github-script@v5 57 | with: 58 | github-token: ${{ secrets.GITHUB_TOKEN }} 59 | script: | 60 | await github.request(`POST /repos/${{ github.repository }}/releases`, { 61 | tag_name: "${{ needs.bump-version.outputs.new-tag }}", 62 | generate_release_notes: true 63 | }) 64 | publish: 65 | name: Publish to npm 66 | runs-on: ubuntu-latest 67 | needs: [bump-version, create-release] 68 | steps: 69 | - name: Checkout ref 70 | uses: actions/checkout@v4 71 | with: 72 | ref: ${{ needs.bump-version.outputs.new-tag }} 73 | - name: Preparation 74 | uses: ./.github/actions/setup 75 | - name: Build package 76 | run: npm run build --if-present 77 | - name: Publish 78 | run: npm publish --provenance --access public 79 | env: 80 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | test/__screenshots__ 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: required 3 | node_js: 4 | - "node" 5 | addons: 6 | chrome: stable 7 | cache: 8 | directories: 9 | - node_modules 10 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/web-systems-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-2020 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Quote selection 2 | 3 | Helpers for quoting selected text, appending the text into a ` 17 | 18 | ``` 19 | 20 | ```js 21 | import {Quote} from '@github/quote-selection' 22 | 23 | document.addEventListener('keydown', event => { 24 | if (event.key == 'r') { 25 | const quote = new Quote() 26 | if (quote.closest('.my-quote-region')) { 27 | quote.insert(document.querySelector('textarea')) 28 | } 29 | } 30 | }) 31 | ``` 32 | 33 | `Quote` will take the currently selected HTML from the specified quote region, convert it to markdown, and create a quoted representation of the selection. 34 | 35 | `insert` will insert the string representation of a selected text into the specified text area field. 36 | 37 | ### Preserving Markdown syntax 38 | 39 | ```js 40 | const quote = new MarkdownQuote('.comment-body') 41 | quote.select(document.querySelector('.comment-body')) 42 | if (quote.closest('.my-quote-region')) { 43 | quote.insert(quote, document.querySelector('textarea')) 44 | } 45 | ``` 46 | 47 | Using `MarkdownQuote` instead of `Quote` will ensure markdown syntax is preserved. 48 | 49 | The optional `scopeSelector` parameter of `MarkdownQuote` 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. 50 | 51 | 52 | ## Development 53 | 54 | ``` 55 | npm install 56 | npm test 57 | ``` 58 | 59 | ## License 60 | 61 | Distributed under the MIT license. See LICENSE for details. 62 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | quote-selection demo 6 | 7 | 8 |

Demo

9 |

Select and press r to quote, m to quote and parse markdown. Selecting this line does not quote.

10 |


11 |
12 |

Blame

13 |

The blame feature in Git describes the last modification to each line of a file, which generally displays the revision, author and time. This is helpful, for example, in tracking down when a feature was added, or which commit led to a particular bug.

14 | 15 |
16 | 17 |

The blame feature in Git describes the last modification to each line of a file, which generally displays the revision, author and time. This is helpful, for example, in tracking down when a feature was added, or which commit led to a particular bug.

18 | 19 | 20 |
21 | 22 |

Branch

23 |

A branch is a parallel version of a repository. It is contained within the repository, but does not affect the primary or master branch allowing you to work freely without disrupting the "live" version. When you've made the changes you want to make, you can merge your branch back into the master branch to publish your changes.

24 | 25 | 26 | 27 |
28 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/quote-selection", 3 | "description": "Add selected text to a text area as a markdown quote.", 4 | "version": "2.0.0", 5 | "main": "dist/umd/index.js", 6 | "module": "dist/index.js", 7 | "types": "dist/index.d.ts", 8 | "license": "MIT", 9 | "repository": "github/quote-selection", 10 | "files": [ 11 | "dist" 12 | ], 13 | "scripts": { 14 | "clean": "rm -rf dist", 15 | "lint": "eslint src/*.ts test/*.js", 16 | "prebuild": "npm run clean && npm run lint", 17 | "build": "tsc && rollup -c", 18 | "test": "vitest run", 19 | "prepublishOnly": "npm run build", 20 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'" 21 | }, 22 | "prettier": "@github/prettier-config", 23 | "eslintConfig": { 24 | "parser": "@typescript-eslint/parser", 25 | "extends": [ 26 | "plugin:github/browser", 27 | "plugin:github/recommended", 28 | "plugin:github/typescript" 29 | ] 30 | }, 31 | "devDependencies": { 32 | "@github/prettier-config": "0.0.4", 33 | "@vitest/browser": "^3.2.4", 34 | "eslint": "^8.0.1", 35 | "eslint-plugin-github": "^4.10.1", 36 | "playwright": "^1.56.0", 37 | "rollup": "^2.4.0", 38 | "typescript": "^4.4.4", 39 | "vitest": "^3.2.4" 40 | }, 41 | "eslintIgnore": [ 42 | "dist/" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json') 2 | 3 | export default { 4 | input: 'dist/index.js', 5 | output: [ 6 | { 7 | file: pkg['module'], 8 | format: 'es' 9 | }, 10 | { 11 | file: pkg['main'], 12 | format: 'umd', 13 | name: 'quoteSelection' 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {extractFragment, insertMarkdownSyntax} from './markdown' 2 | 3 | type ProcessSelectionTextFn = (str: string) => string 4 | 5 | export class Quote { 6 | selection = window.getSelection() 7 | processSelectionText: ProcessSelectionTextFn = str => str 8 | 9 | closest(selector: string): Element | null { 10 | const startContainer = this.range.startContainer 11 | const startElement: Element | null = 12 | startContainer instanceof Element ? startContainer : startContainer.parentElement 13 | if (!startElement) return null 14 | 15 | return startElement.closest(selector) 16 | } 17 | 18 | get active(): boolean { 19 | return (this.selection?.rangeCount || 0) > 0 20 | } 21 | 22 | get range(): Range { 23 | return this.selection?.rangeCount ? this.selection.getRangeAt(0) : new Range() 24 | } 25 | 26 | set range(range: Range) { 27 | this.selection?.removeAllRanges() 28 | this.selection?.addRange(range) 29 | } 30 | 31 | set processSelectionTextFn(fn: ProcessSelectionTextFn) { 32 | this.processSelectionText = fn 33 | } 34 | 35 | get selectionText(): string { 36 | return this.processSelectionText(this.selection?.toString().trim() || '') 37 | } 38 | 39 | get quotedText(): string { 40 | return `> ${this.selectionText.replace(/\n/g, '\n> ')}\n\n` 41 | } 42 | 43 | select(element: Element) { 44 | if (this.selection) { 45 | this.selection.removeAllRanges() 46 | this.selection.selectAllChildren(element) 47 | } 48 | } 49 | 50 | insert(field: HTMLTextAreaElement) { 51 | if (field.value) { 52 | field.value = `${field.value}\n\n${this.quotedText}` 53 | } else { 54 | field.value = this.quotedText 55 | } 56 | 57 | field.dispatchEvent( 58 | new CustomEvent('change', { 59 | bubbles: true, 60 | cancelable: false 61 | }) 62 | ) 63 | field.focus() 64 | field.selectionStart = field.value.length 65 | field.scrollTop = field.scrollHeight 66 | } 67 | } 68 | 69 | export class MarkdownQuote extends Quote { 70 | constructor( 71 | private scopeSelector = '', 72 | private callback?: (fragment: DocumentFragment) => void 73 | ) { 74 | super() 75 | } 76 | 77 | get selectionText() { 78 | if (!this.selection) return '' 79 | const fragment = extractFragment(this.range, this.scopeSelector ?? '') 80 | this.callback?.(fragment) 81 | insertMarkdownSyntax(fragment) 82 | const body = document.body 83 | if (!body) return '' 84 | 85 | const div = document.createElement('div') 86 | div.appendChild(fragment) 87 | div.style.cssText = 'position:absolute;left:-9999px;' 88 | body.appendChild(div) 89 | let selectionText = '' 90 | try { 91 | const range = document.createRange() 92 | range.selectNodeContents(div) 93 | this.selection.removeAllRanges() 94 | this.selection.addRange(range) 95 | selectionText = this.selection.toString() 96 | this.selection.removeAllRanges() 97 | range.detach() 98 | } finally { 99 | body.removeChild(div) 100 | } 101 | return this.processSelectionText(selectionText.trim()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/markdown.ts: -------------------------------------------------------------------------------- 1 | function indexInList(li: Element): number { 2 | const parent = li.parentNode 3 | 4 | if (parent === null || !(parent instanceof HTMLElement)) throw new Error() 5 | 6 | let start = 0 7 | if (parent instanceof HTMLOListElement && parent.start !== 1) { 8 | start = parent.start - 1 9 | } 10 | 11 | const ref = parent.children 12 | for (let i = 0; i < ref.length; ++i) { 13 | if (ref[i] === li) { 14 | return start + i 15 | } 16 | } 17 | return start 18 | } 19 | 20 | // Skip processing links that only link to the src of image within. 21 | function skipNode(node: Node): boolean { 22 | if (node instanceof HTMLAnchorElement && node.childNodes.length === 1) { 23 | const first = node.childNodes[0] 24 | if (first instanceof HTMLImageElement) { 25 | return first.src === node.href 26 | } 27 | } 28 | return false 29 | } 30 | 31 | function hasContent(node: Node): boolean { 32 | return node.nodeName === 'IMG' || node.firstChild != null 33 | } 34 | 35 | function isCheckbox(node: Node): boolean { 36 | return node.nodeName === 'INPUT' && node instanceof HTMLInputElement && node.type === 'checkbox' 37 | } 38 | 39 | let listIndexOffset = 0 40 | 41 | function nestedListExclusive(li: Element): boolean { 42 | const first = li.childNodes[0] 43 | const second = li.childNodes[1] 44 | if (first && li.childNodes.length < 3) { 45 | return ( 46 | (first.nodeName === 'OL' || first.nodeName === 'UL') && 47 | (!second || (second.nodeType === Node.TEXT_NODE && !(second.textContent || '').trim())) 48 | ) 49 | } 50 | 51 | return false 52 | } 53 | 54 | function escapeAttribute(text: string): string { 55 | return text 56 | .replace(/&/g, '&') 57 | .replace(/'/g, ''') 58 | .replace(/"/g, '"') 59 | .replace(//g, '>') 61 | } 62 | 63 | type Filters = { 64 | [key: string]: (el: HTMLElement) => string | HTMLElement 65 | } 66 | 67 | const filters: Filters = { 68 | INPUT(el) { 69 | if (el instanceof HTMLInputElement && el.checked) { 70 | return '[x] ' 71 | } 72 | return '[ ] ' 73 | }, 74 | CODE(el) { 75 | const text = el.textContent || '' 76 | 77 | if (el.parentNode && el.parentNode.nodeName === 'PRE') { 78 | el.textContent = `\`\`\`\n${text.replace(/\n+$/, '')}\n\`\`\`\n\n` 79 | return el 80 | } 81 | if (text.indexOf('`') >= 0) { 82 | return `\`\` ${text} \`\`` 83 | } 84 | return `\`${text}\`` 85 | }, 86 | P(el) { 87 | const pElement = document.createElement('p') 88 | const text = el.textContent || '' 89 | pElement.textContent = text.replace(/<(\/?)(pre|strong|weak|em)>/g, '\\<$1$2\\>') 90 | return pElement 91 | }, 92 | STRONG(el) { 93 | return `**${el.textContent || ''}**` 94 | }, 95 | EM(el) { 96 | return `_${el.textContent || ''}_` 97 | }, 98 | DEL(el) { 99 | return `~${el.textContent || ''}~` 100 | }, 101 | BLOCKQUOTE(el) { 102 | const text = (el.textContent || '').trim().replace(/^/gm, '> ') 103 | const pre = document.createElement('pre') 104 | pre.textContent = `${text}\n\n` 105 | return pre 106 | }, 107 | A(el) { 108 | const text = el.textContent || '' 109 | const href = el.getAttribute('href') 110 | 111 | if (/^https?:/.test(text) && text === href) { 112 | return text 113 | } else { 114 | if (href) { 115 | return `[${text}](${href})` 116 | } else { 117 | return text 118 | } 119 | } 120 | }, 121 | IMG(el) { 122 | const alt = el.getAttribute('alt') || '' 123 | const src = el.getAttribute('src') 124 | if (!src) throw new Error() 125 | 126 | const widthAttr = el.hasAttribute('width') ? ` width="${escapeAttribute(el.getAttribute('width') || '')}"` : '' 127 | const heightAttr = el.hasAttribute('height') ? ` height="${escapeAttribute(el.getAttribute('height') || '')}"` : '' 128 | 129 | if (widthAttr || heightAttr) { 130 | // eslint-disable-next-line github/unescaped-html-literal 131 | return `${escapeAttribute(alt)}` 132 | } else { 133 | return `![${alt}](${src})` 134 | } 135 | }, 136 | LI(el) { 137 | const list = el.parentNode 138 | if (!list) throw new Error() 139 | 140 | let bullet = '' 141 | if (!nestedListExclusive(el)) { 142 | if (list.nodeName === 'OL') { 143 | if (listIndexOffset > 0 && !list.previousSibling) { 144 | const num = indexInList(el) + listIndexOffset + 1 145 | bullet = `${num}\\. ` 146 | } else { 147 | bullet = `${indexInList(el) + 1}. ` 148 | } 149 | } else { 150 | bullet = '* ' 151 | } 152 | } 153 | 154 | const indent = bullet.replace(/\S/g, ' ') 155 | const text = (el.textContent || '').trim().replace(/^/gm, indent) 156 | const pre = document.createElement('pre') 157 | pre.textContent = text.replace(indent, bullet) 158 | return pre 159 | }, 160 | OL(el) { 161 | const li = document.createElement('li') 162 | li.appendChild(document.createElement('br')) 163 | el.append(li) 164 | return el 165 | }, 166 | H1(el) { 167 | const level = parseInt(el.nodeName.slice(1)) 168 | el.prepend(`${Array(level + 1).join('#')} `) 169 | return el 170 | }, 171 | UL(el) { 172 | return el 173 | } 174 | } 175 | filters.UL = filters.OL 176 | for (let level = 2; level <= 6; ++level) { 177 | filters[`H${level}`] = filters.H1 178 | } 179 | 180 | export function insertMarkdownSyntax(root: DocumentFragment): void { 181 | const nodeIterator = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, { 182 | acceptNode(node: Node) { 183 | if (node.nodeName in filters && !skipNode(node) && (hasContent(node) || isCheckbox(node))) { 184 | return NodeFilter.FILTER_ACCEPT 185 | } 186 | 187 | return NodeFilter.FILTER_SKIP 188 | } 189 | }) 190 | const results: HTMLElement[] = [] 191 | let node = nodeIterator.nextNode() 192 | 193 | while (node) { 194 | if (node instanceof HTMLElement) { 195 | results.push(node) 196 | } 197 | node = nodeIterator.nextNode() 198 | } 199 | 200 | // process deepest matches first 201 | results.reverse() 202 | 203 | for (const el of results) { 204 | el.replaceWith(filters[el.nodeName](el)) 205 | } 206 | } 207 | 208 | export function extractFragment(range: Range, selector: string): DocumentFragment { 209 | const startNode = range.startContainer 210 | if (!startNode || !startNode.parentNode || !(startNode.parentNode instanceof HTMLElement)) { 211 | throw new Error('the range must start within an HTMLElement') 212 | } 213 | const parent = startNode.parentNode 214 | 215 | let fragment = range.cloneContents() 216 | if (selector) { 217 | const contentElement = fragment.querySelector(selector) 218 | if (contentElement) { 219 | fragment = document.createDocumentFragment() 220 | fragment.appendChild(contentElement) 221 | } 222 | } 223 | 224 | listIndexOffset = 0 225 | const li = parent.closest('li') 226 | const codeBlock = parent.closest('pre') 227 | if (codeBlock) { 228 | const pre = document.createElement('pre') 229 | pre.appendChild(fragment) 230 | fragment = document.createDocumentFragment() 231 | fragment.appendChild(pre) 232 | } else if (li && li.parentNode) { 233 | if (li.parentNode.nodeName === 'OL') { 234 | listIndexOffset = indexInList(li) 235 | } 236 | if (!fragment.querySelector('li')) { 237 | const item = document.createElement('li') 238 | 239 | if (!li.parentNode) throw new Error() 240 | const list = document.createElement(li.parentNode.nodeName) 241 | 242 | item.appendChild(fragment) 243 | list.appendChild(item) 244 | fragment = document.createDocumentFragment() 245 | fragment.appendChild(list) 246 | } 247 | } 248 | 249 | return fragment 250 | } 251 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "espree", 3 | "parserOptions": { 4 | "ecmaVersion": 8, 5 | "sourceType": "module" 6 | }, 7 | "rules": { 8 | "i18n-text/no-en": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/named 2 | import {describe, it, beforeEach, afterEach, expect} from 'vitest' 3 | import {MarkdownQuote, Quote} from '../src/index' 4 | 5 | function createSelection(selection, el) { 6 | const range = document.createRange() 7 | range.selectNodeContents(el) 8 | selection.removeAllRanges() 9 | selection.addRange(range) 10 | return selection 11 | } 12 | 13 | describe('quote-selection', function () { 14 | describe('with quotable selection', function () { 15 | beforeEach(function () { 16 | // eslint-disable-next-line github/no-inner-html 17 | document.body.innerHTML = ` 18 |

Not quotable text

19 |
20 |

Test Quotable text, bold.

21 |
22 |

Nested text.

23 | 24 |
25 | 26 |
27 | ` 28 | }) 29 | 30 | const oldGetSelection = window.getSelection 31 | afterEach(function () { 32 | window.getSelection = oldGetSelection 33 | // eslint-disable-next-line github/no-inner-html 34 | document.body.innerHTML = '' 35 | }) 36 | 37 | it('textarea is updated', function () { 38 | const el = document.querySelector('#quotable') 39 | const selection = window.getSelection() 40 | window.getSelection = () => createSelection(selection, el) 41 | 42 | const textarea = document.querySelector('#not-hidden-textarea') 43 | let changeCount = 0 44 | 45 | textarea.addEventListener('change', function () { 46 | changeCount++ 47 | }) 48 | const quote = new Quote() 49 | expect(quote.active).toBeTruthy() 50 | expect(quote.closest('[data-quote], [data-nested-quote]')).toBeTruthy() 51 | quote.insert(textarea) 52 | 53 | expect(textarea.value).toBe('Has text\n\n> Test Quotable text, bold.\n\n') 54 | expect(changeCount).toBe(1) 55 | }) 56 | 57 | it('nested textarea is updated when event is captured', function () { 58 | const el = document.querySelector('#nested-quotable') 59 | const selection = window.getSelection() 60 | window.getSelection = () => createSelection(selection, el) 61 | const textarea = document.querySelector('#nested-textarea') 62 | const outerTextarea = document.querySelector('#not-hidden-textarea') 63 | 64 | textarea.hidden = false 65 | 66 | const quote = new Quote() 67 | expect(quote.active).toBeTruthy() 68 | expect(quote.closest('[data-quote], [data-nested-quote]')).toBeTruthy() 69 | quote.insert(textarea) 70 | 71 | expect(outerTextarea.value).toBe('Has text') 72 | expect(textarea.value).toBe('Has text\n\n> Nested text.\n\n') 73 | }) 74 | 75 | it('textarea is not updated when selecting text outside of quote region', function () { 76 | const el = document.querySelector('#not-quotable') 77 | const selection = window.getSelection() 78 | window.getSelection = () => createSelection(selection, el) 79 | 80 | const quote = new Quote() 81 | 82 | expect(quote.active).toBeTruthy() 83 | expect(quote.closest('[data-quote], [data-nested-quote]')).toBe(null) 84 | }) 85 | 86 | it('is not active if nothing is selected', function () { 87 | window.getSelection().removeAllRanges() 88 | const quote = new Quote() 89 | expect(quote.active).toBeFalsy() 90 | }) 91 | 92 | it('range can be set', function () { 93 | const el = document.querySelector('#quotable') 94 | const textarea = document.querySelector('#not-hidden-textarea') 95 | const selection = window.getSelection() 96 | window.getSelection = () => createSelection(selection, el) 97 | 98 | const quote = new Quote() 99 | quote.range = document.createRange() 100 | quote.range.selectNodeContents(el.querySelector('strong')) 101 | quote.insert(textarea) 102 | 103 | expect(textarea.value).toBe('Has text\n\n> bold\n\n') 104 | }) 105 | 106 | it('allows processing the quoted text before inserting it', function () { 107 | const el = document.querySelector('#quotable') 108 | const selection = window.getSelection() 109 | window.getSelection = () => createSelection(selection, el) 110 | 111 | const textarea = document.querySelector('#not-hidden-textarea') 112 | const quote = new Quote() 113 | quote.processSelectionTextFn = text => text.replace('Quotable', 'replaced') 114 | 115 | quote.insert(textarea) 116 | 117 | expect(textarea.value).toBe('Has text\n\n> Test replaced text, bold.\n\n') 118 | }) 119 | }) 120 | 121 | describe('with markdown enabled', function () { 122 | beforeEach(function () { 123 | // eslint-disable-next-line github/no-inner-html 124 | document.body.innerHTML = ` 125 |
126 |
127 |

This should not appear as part of the quote.

128 |
129 |

This is beautifully formatted text that even has some inline code.

130 |

This is a simple p line

131 |

some escaped html tags to ignore <pre> <strong> <weak> <em> </pre> </strong> </weak> </em>

132 |
foo(true)
133 |

Links and :emoji: are preserved.

134 |

Music changes, and I'm gonna change right along with it.
--Aretha Franklin

135 |
136 |
137 | 138 |
139 | ` 140 | }) 141 | 142 | afterEach(function () { 143 | // eslint-disable-next-line github/no-inner-html 144 | document.body.innerHTML = '' 145 | }) 146 | 147 | it('preserves formatting', function () { 148 | const quote = new MarkdownQuote('.comment-body') 149 | quote.select(document.querySelector('.comment-body')) 150 | expect(quote.closest('[data-quote]')).toBeTruthy() 151 | const textarea = document.querySelector('textarea') 152 | quote.insert(textarea) 153 | 154 | expect(textarea.value.replace(/ +\n/g, '\n')).toBe( 155 | `> This is **beautifully** formatted _text_ that even has some \`inline code\`. 156 | > 157 | > This is a simple p line 158 | > 159 | > some escaped html tags to ignore \\ \\ \\ \\ \\ \\ \\ \\ 160 | > 161 | > \`\`\` 162 | > foo(true) 163 | > \`\`\` 164 | > 165 | > [Links](http://example.com) and ![:emoji:](image.png) are preserved. 166 | > 167 | > > Music changes, and I'm gonna change right along with it.--Aretha Franklin 168 | 169 | ` 170 | ) 171 | }) 172 | 173 | it('provides a callback to mutate markup', function () { 174 | const quote = new MarkdownQuote('.comment-body', fragment => { 175 | fragment.querySelector('a[href]').replaceWith('@links') 176 | fragment.querySelector('img[alt]').replaceWith(':emoji:') 177 | }) 178 | quote.select(document.querySelector('.comment-body')) 179 | expect(quote.active).toBeTruthy() 180 | expect(quote.closest('[data-quote]')).toBeTruthy() 181 | 182 | const textarea = document.querySelector('textarea') 183 | quote.insert(textarea) 184 | 185 | expect(textarea.value).toMatch(/^> @links and :emoji: are preserved\./m) 186 | }) 187 | 188 | it('preserves list order', function () { 189 | // eslint-disable-next-line github/no-inner-html 190 | document.getElementById('comment-body').innerHTML = ` 191 |
    192 |
  1. Top level list one 193 | 210 |
  2. 211 |
  3. Top level list two
  4. 212 |
  5. Top level list three 213 |
      214 |
    1. sublist one
    2. 215 |
    3. sublist two
    4. 216 |
    5. sublist three
    6. 217 |
    218 |
  6. 219 |
220 | ` 221 | 222 | const quote = new MarkdownQuote('.comment-body') 223 | quote.select(document.querySelector('.comment-body')) 224 | expect(quote.closest('[data-quote]')).toBeTruthy() 225 | const textarea = document.querySelector('textarea') 226 | quote.insert(textarea) 227 | 228 | expect(textarea.value.replace(/ +\n/g, '\n')).toBe( 229 | `> 1. Top level list one 230 | > 231 | > * 1. sublist one 232 | > * 2. sublist two 233 | > * 5. sublist three 234 | > 2. Top level list two 235 | > 3. Top level list three 236 | > 237 | > 1. sublist one 238 | > 2. sublist two 239 | > 3. sublist three 240 | 241 | ` 242 | ) 243 | }) 244 | }) 245 | }) 246 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2015", 4 | "target": "es2018", 5 | "strict": true, 6 | "declaration": true, 7 | "outDir": "dist", 8 | "removeComments": true 9 | }, 10 | "files": [ 11 | "src/index.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['test/**/*.js'], 6 | browser: { 7 | enabled: true, 8 | provider: 'playwright', 9 | headless: true, 10 | instances: [ 11 | { 12 | browser: 'chromium' 13 | } 14 | ] 15 | } 16 | } 17 | }) 18 | --------------------------------------------------------------------------------