├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.json ├── .github └── workflows │ ├── nodejs.yml │ └── publish.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── examples └── index.html ├── package-lock.json ├── package.json ├── src └── index.ts ├── test ├── .eslintrc.json ├── karma.config.cjs └── test.js └── tsconfig.json /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="16" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source/usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node modules 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.222.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 8 | // Append -bullseye or -buster to pin to an OS version. 9 | // Use -bullseye variants on local arm64/Apple Silicon. 10 | "args": { "VARIANT": "16" } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | // Add the IDs of extensions you want installed when the container is created. 17 | "extensions": [ 18 | "dbaeumer.vscode-eslint" 19 | ], 20 | 21 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 22 | // "forwardPorts": [], 23 | 24 | // Use 'postCreateCommand' to run commands after the container is created. 25 | // "postCreateCommand": "yarn install", 26 | 27 | // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 28 | "remoteUser": "node", 29 | "features": { 30 | "git": "latest" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["plugin:github/browser", "plugin:github/recommended", "plugin:github/typescript"], 4 | "globals": { 5 | "MarkdownToolbarElement": "readonly", 6 | "MarkdownHeaderButtonElement": "readonly", 7 | "MarkdownBoldButtonElement": "readonly", 8 | "MarkdownItalicButtonElement": "readonly", 9 | "MarkdownQuoteButtonElement": "readonly", 10 | "MarkdownCodeButtonElement": "readonly", 11 | "MarkdownLinkButtonElement": "readonly", 12 | "MarkdownImageButtonElement": "readonly", 13 | "MarkdownUnorderedListButtonElement": "readonly", 14 | "MarkdownOrderedListButtonElement": "readonly", 15 | "MarkdownTaskListButtonElement": "readonly", 16 | "MarkdownMentionButtonElement": "readonly", 17 | "MarkdownRefButtonElement": "readonly" 18 | }, 19 | "overrides": [ 20 | { 21 | "files": "test/**/*.js", 22 | "rules": { 23 | "github/unescaped-html-literal": "off", 24 | "github/no-inner-html": "off", 25 | "i18n-text/no-en": "off" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js 12.x 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: npm install, and test 19 | run: | 20 | npm install 21 | npm test 22 | env: 23 | CI: true 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org/ 16 | cache: npm 17 | - run: npm ci 18 | - run: npm test 19 | - run: npm version ${TAG_NAME} --git-tag-version=false 20 | env: 21 | TAG_NAME: ${{ github.event.release.tag_name }} 22 | - run: npm whoami; npm --ignore-scripts publish 23 | env: 24 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @github/primer-reviewers 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2018 GitHub, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <markdown-toolbar> element 2 | 3 | Markdown formatting buttons for text inputs. 4 | 5 | ## Installation 6 | 7 | ``` 8 | $ npm install --save @github/markdown-toolbar-element 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```js 14 | import '@github/markdown-toolbar-element' 15 | ``` 16 | 17 | ```html 18 | 19 | bold 20 | header 21 | italic 22 | quote 23 | code 24 | link 25 | image 26 | unordered-list 27 | ordered-list 28 | task-list 29 | mention 30 | ref 31 | 32 | 33 | 34 | ``` 35 | 36 | `` comes with focus management as advised in [WAI-ARIA Authoring Practices 1.1: Toolbar Design Pattern](https://www.w3.org/TR/wai-aria-practices-1.1/examples/toolbar/toolbar.html). The `md-*` buttons that ship with this package are automatically managed. Add a `data-md-button` attribute to any custom toolbar items to enroll them into focus management. 37 | 38 | ## Browser support 39 | 40 | Browsers without native [custom element support][support] require a [polyfill][]. 41 | 42 | - Chrome 43 | - Firefox 44 | - Safari 45 | - Microsoft Edge 46 | 47 | [support]: https://caniuse.com/#feat=custom-elementsv1 48 | [polyfill]: https://github.com/webcomponents/custom-elements 49 | 50 | ## Development 51 | 52 | ``` 53 | npm install 54 | npm test 55 | ``` 56 | 57 | ## License 58 | 59 | Distributed under the MIT license. See LICENSE for details. 60 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | markdown-toolbar examples 5 | 6 | 7 | 8 |
9 |

10 | 11 | bold 12 | header 13 | italic 14 | quote 15 | code 16 | link 17 | image 18 | unordered-list 19 | ordered-list 20 | task-list 21 | mention 22 | ref 23 | strikethrough 24 | 25 | 26 |
27 | 28 |
29 | Initially hidden toolbar! 30 |
31 |

32 | 33 | bold 34 | header 35 | italic 36 | quote 37 | code 38 | link 39 | image 40 | unordered-list 41 | ordered-list 42 | task-list 43 | mention 44 | ref 45 | strikethrough 46 | 47 | 48 |
49 |
50 | 51 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@github/markdown-toolbar-element", 3 | "version": "2.1.0", 4 | "description": "Markdown formatting buttons for text inputs.", 5 | "repository": "github/markdown-toolbar-element", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "module": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "scripts": { 11 | "clean": "rm -rf dist", 12 | "lint": "eslint src/*.ts test/*.js", 13 | "prebuild": "npm run clean && npm run lint && mkdir dist", 14 | "build": "tsc", 15 | "pretest": "npm run build", 16 | "test": "karma start test/karma.config.cjs", 17 | "prepublishOnly": "npm run build", 18 | "postpublish": "npm publish --ignore-scripts --@github:registry='https://npm.pkg.github.com'" 19 | }, 20 | "keywords": [ 21 | "custom-element", 22 | "markdown" 23 | ], 24 | "prettier": "@github/prettier-config", 25 | "license": "MIT", 26 | "files": [ 27 | "dist" 28 | ], 29 | "devDependencies": { 30 | "@github/prettier-config": "0.0.4", 31 | "chai": "^4.3.4", 32 | "chromium": "^3.0.3", 33 | "eslint": "^8.6.0", 34 | "eslint-plugin-github": "^4.3.5", 35 | "karma": "^6.3.2", 36 | "karma-chai": "^0.1.0", 37 | "karma-chrome-launcher": "^3.1.0", 38 | "karma-mocha": "^2.0.1", 39 | "karma-mocha-reporter": "^2.2.3", 40 | "mocha": "^10.0.0", 41 | "typescript": "^4.2.4" 42 | }, 43 | "eslintIgnore": [ 44 | "dist/" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Window { 3 | MarkdownToolbarElement: typeof MarkdownToolbarElement 4 | MarkdownHeaderButtonElement: typeof MarkdownHeaderButtonElement 5 | MarkdownBoldButtonElement: typeof MarkdownBoldButtonElement 6 | MarkdownItalicButtonElement: typeof MarkdownItalicButtonElement 7 | MarkdownQuoteButtonElement: typeof MarkdownQuoteButtonElement 8 | MarkdownCodeButtonElement: typeof MarkdownCodeButtonElement 9 | MarkdownLinkButtonElement: typeof MarkdownLinkButtonElement 10 | MarkdownImageButtonElement: typeof MarkdownImageButtonElement 11 | MarkdownUnorderedListButtonElement: typeof MarkdownUnorderedListButtonElement 12 | MarkdownOrderedListButtonElement: typeof MarkdownOrderedListButtonElement 13 | MarkdownTaskListButtonElement: typeof MarkdownTaskListButtonElement 14 | MarkdownMentionButtonElement: typeof MarkdownMentionButtonElement 15 | MarkdownRefButtonElement: typeof MarkdownRefButtonElement 16 | MarkdownStrikethroughButtonElement: typeof MarkdownStrikethroughButtonElement 17 | } 18 | interface HTMLElementTagNameMap { 19 | 'markdown-toolbar': MarkdownToolbarElement 20 | 'md-header': MarkdownHeaderButtonElement 21 | 'md-bold': MarkdownBoldButtonElement 22 | 'md-italic': MarkdownItalicButtonElement 23 | 'md-quote': MarkdownQuoteButtonElement 24 | 'md-code': MarkdownCodeButtonElement 25 | 'md-link': MarkdownLinkButtonElement 26 | 'md-image': MarkdownImageButtonElement 27 | 'md-unordered-list': MarkdownUnorderedListButtonElement 28 | 'md-ordered-list': MarkdownOrderedListButtonElement 29 | 'md-task-list': MarkdownTaskListButtonElement 30 | 'md-mention': MarkdownMentionButtonElement 31 | 'md-ref': MarkdownRefButtonElement 32 | 'md-strikethrough': MarkdownStrikethroughButtonElement 33 | } 34 | } 35 | 36 | const buttonSelectors = [ 37 | '[data-md-button]', 38 | 'md-header', 39 | 'md-bold', 40 | 'md-italic', 41 | 'md-quote', 42 | 'md-code', 43 | 'md-link', 44 | 'md-image', 45 | 'md-unordered-list', 46 | 'md-ordered-list', 47 | 'md-task-list', 48 | 'md-mention', 49 | 'md-ref', 50 | 'md-strikethrough' 51 | ] 52 | function getButtons(toolbar: Element): HTMLElement[] { 53 | const els = [] 54 | for (const button of toolbar.querySelectorAll(buttonSelectors.join(', '))) { 55 | // Skip buttons that are hidden, either via `hidden` attribute or CSS: 56 | if (button.hidden || (button.offsetWidth <= 0 && button.offsetHeight <= 0)) continue 57 | if (button.closest('markdown-toolbar') === toolbar) els.push(button) 58 | } 59 | return els 60 | } 61 | 62 | function keydown(fn: (event: KeyboardEvent) => void): (event: KeyboardEvent) => void { 63 | return function (event: KeyboardEvent) { 64 | if (event.key === ' ' || event.key === 'Enter') { 65 | fn(event) 66 | } 67 | } 68 | } 69 | 70 | type Style = { 71 | prefix?: string 72 | suffix?: string 73 | trimFirst?: boolean 74 | multiline?: boolean 75 | surroundWithNewlines?: boolean 76 | blockPrefix?: string 77 | blockSuffix?: string 78 | replaceNext?: string 79 | scanFor?: string 80 | orderedList?: boolean 81 | unorderedList?: boolean 82 | prefixSpace?: boolean 83 | } 84 | 85 | const styles = new WeakMap() 86 | const manualStyles = { 87 | 'header-1': {prefix: '# '}, 88 | 'header-2': {prefix: '## '}, 89 | 'header-3': {prefix: '### '}, 90 | 'header-4': {prefix: '#### '}, 91 | 'header-5': {prefix: '##### '}, 92 | 'header-6': {prefix: '###### '}, 93 | bold: {prefix: '**', suffix: '**', trimFirst: true}, 94 | italic: {prefix: '_', suffix: '_', trimFirst: true}, 95 | quote: {prefix: '> ', multiline: true, surroundWithNewlines: true}, 96 | code: { 97 | prefix: '`', 98 | suffix: '`', 99 | blockPrefix: '```', 100 | blockSuffix: '```' 101 | }, 102 | link: {prefix: '[', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://'}, 103 | image: {prefix: '![', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://'}, 104 | 'unordered-list': { 105 | prefix: '- ', 106 | multiline: true, 107 | unorderedList: true 108 | }, 109 | 'ordered-list': { 110 | prefix: '1. ', 111 | multiline: true, 112 | orderedList: true 113 | }, 114 | 'task-list': {prefix: '- [ ] ', multiline: true, surroundWithNewlines: true}, 115 | mention: {prefix: '@', prefixSpace: true}, 116 | ref: {prefix: '#', prefixSpace: true}, 117 | strikethrough: {prefix: '~~', suffix: '~~', trimFirst: true} 118 | } as const 119 | 120 | class MarkdownButtonElement extends HTMLElement { 121 | constructor() { 122 | super() 123 | const apply = (event: Event) => { 124 | const style = styles.get(this) 125 | if (!style) return 126 | event.preventDefault() 127 | applyStyle(this, style) 128 | } 129 | this.addEventListener('keydown', keydown(apply)) 130 | this.addEventListener('click', apply) 131 | } 132 | 133 | connectedCallback() { 134 | if (!this.hasAttribute('role')) { 135 | this.setAttribute('role', 'button') 136 | } 137 | } 138 | 139 | click() { 140 | const style = styles.get(this) 141 | if (!style) return 142 | applyStyle(this, style) 143 | } 144 | } 145 | 146 | class MarkdownHeaderButtonElement extends MarkdownButtonElement { 147 | connectedCallback() { 148 | const level = parseInt(this.getAttribute('level') || '3', 10) 149 | this.#setLevelStyle(level) 150 | } 151 | 152 | static get observedAttributes() { 153 | return ['level'] 154 | } 155 | 156 | attributeChangedCallback(name: string, oldValue: string, newValue: string) { 157 | if (name !== 'level') return 158 | const level = parseInt(newValue || '3', 10) 159 | this.#setLevelStyle(level) 160 | } 161 | 162 | #setLevelStyle(level: number) { 163 | if (level < 1 || level > 6) { 164 | return 165 | } 166 | 167 | const prefix = `${'#'.repeat(level)} ` 168 | styles.set(this, { 169 | prefix 170 | }) 171 | } 172 | } 173 | 174 | if (!window.customElements.get('md-header')) { 175 | window.MarkdownHeaderButtonElement = MarkdownHeaderButtonElement 176 | window.customElements.define('md-header', MarkdownHeaderButtonElement) 177 | } 178 | 179 | class MarkdownBoldButtonElement extends MarkdownButtonElement { 180 | connectedCallback() { 181 | styles.set(this, {prefix: '**', suffix: '**', trimFirst: true}) 182 | } 183 | } 184 | 185 | if (!window.customElements.get('md-bold')) { 186 | window.MarkdownBoldButtonElement = MarkdownBoldButtonElement 187 | window.customElements.define('md-bold', MarkdownBoldButtonElement) 188 | } 189 | 190 | class MarkdownItalicButtonElement extends MarkdownButtonElement { 191 | connectedCallback() { 192 | styles.set(this, {prefix: '_', suffix: '_', trimFirst: true}) 193 | } 194 | } 195 | 196 | if (!window.customElements.get('md-italic')) { 197 | window.MarkdownItalicButtonElement = MarkdownItalicButtonElement 198 | window.customElements.define('md-italic', MarkdownItalicButtonElement) 199 | } 200 | 201 | class MarkdownQuoteButtonElement extends MarkdownButtonElement { 202 | connectedCallback() { 203 | styles.set(this, {prefix: '> ', multiline: true, surroundWithNewlines: true}) 204 | } 205 | } 206 | 207 | if (!window.customElements.get('md-quote')) { 208 | window.MarkdownQuoteButtonElement = MarkdownQuoteButtonElement 209 | window.customElements.define('md-quote', MarkdownQuoteButtonElement) 210 | } 211 | 212 | class MarkdownCodeButtonElement extends MarkdownButtonElement { 213 | connectedCallback() { 214 | styles.set(this, {prefix: '`', suffix: '`', blockPrefix: '```', blockSuffix: '```'}) 215 | } 216 | } 217 | 218 | if (!window.customElements.get('md-code')) { 219 | window.MarkdownCodeButtonElement = MarkdownCodeButtonElement 220 | window.customElements.define('md-code', MarkdownCodeButtonElement) 221 | } 222 | 223 | class MarkdownLinkButtonElement extends MarkdownButtonElement { 224 | connectedCallback() { 225 | styles.set(this, {prefix: '[', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://'}) 226 | } 227 | } 228 | 229 | if (!window.customElements.get('md-link')) { 230 | window.MarkdownLinkButtonElement = MarkdownLinkButtonElement 231 | window.customElements.define('md-link', MarkdownLinkButtonElement) 232 | } 233 | 234 | class MarkdownImageButtonElement extends MarkdownButtonElement { 235 | connectedCallback() { 236 | styles.set(this, {prefix: '![', suffix: '](url)', replaceNext: 'url', scanFor: 'https?://'}) 237 | } 238 | } 239 | 240 | if (!window.customElements.get('md-image')) { 241 | window.MarkdownImageButtonElement = MarkdownImageButtonElement 242 | window.customElements.define('md-image', MarkdownImageButtonElement) 243 | } 244 | 245 | class MarkdownUnorderedListButtonElement extends MarkdownButtonElement { 246 | connectedCallback() { 247 | styles.set(this, {prefix: '- ', multiline: true, unorderedList: true}) 248 | } 249 | } 250 | 251 | if (!window.customElements.get('md-unordered-list')) { 252 | window.MarkdownUnorderedListButtonElement = MarkdownUnorderedListButtonElement 253 | window.customElements.define('md-unordered-list', MarkdownUnorderedListButtonElement) 254 | } 255 | 256 | class MarkdownOrderedListButtonElement extends MarkdownButtonElement { 257 | connectedCallback() { 258 | styles.set(this, {prefix: '1. ', multiline: true, orderedList: true}) 259 | } 260 | } 261 | 262 | if (!window.customElements.get('md-ordered-list')) { 263 | window.MarkdownOrderedListButtonElement = MarkdownOrderedListButtonElement 264 | window.customElements.define('md-ordered-list', MarkdownOrderedListButtonElement) 265 | } 266 | 267 | class MarkdownTaskListButtonElement extends MarkdownButtonElement { 268 | connectedCallback() { 269 | styles.set(this, {prefix: '- [ ] ', multiline: true, surroundWithNewlines: true}) 270 | } 271 | } 272 | 273 | if (!window.customElements.get('md-task-list')) { 274 | window.MarkdownTaskListButtonElement = MarkdownTaskListButtonElement 275 | window.customElements.define('md-task-list', MarkdownTaskListButtonElement) 276 | } 277 | 278 | class MarkdownMentionButtonElement extends MarkdownButtonElement { 279 | connectedCallback() { 280 | styles.set(this, {prefix: '@', prefixSpace: true}) 281 | } 282 | } 283 | 284 | if (!window.customElements.get('md-mention')) { 285 | window.MarkdownMentionButtonElement = MarkdownMentionButtonElement 286 | window.customElements.define('md-mention', MarkdownMentionButtonElement) 287 | } 288 | 289 | class MarkdownRefButtonElement extends MarkdownButtonElement { 290 | connectedCallback() { 291 | styles.set(this, {prefix: '#', prefixSpace: true}) 292 | } 293 | } 294 | 295 | if (!window.customElements.get('md-ref')) { 296 | window.MarkdownRefButtonElement = MarkdownRefButtonElement 297 | window.customElements.define('md-ref', MarkdownRefButtonElement) 298 | } 299 | 300 | class MarkdownStrikethroughButtonElement extends MarkdownButtonElement { 301 | connectedCallback() { 302 | styles.set(this, {prefix: '~~', suffix: '~~', trimFirst: true}) 303 | } 304 | } 305 | 306 | if (!window.customElements.get('md-strikethrough')) { 307 | window.MarkdownStrikethroughButtonElement = MarkdownStrikethroughButtonElement 308 | window.customElements.define('md-strikethrough', MarkdownStrikethroughButtonElement) 309 | } 310 | 311 | function applyFromToolbar(event: Event) { 312 | const {target, currentTarget} = event 313 | if (!(target instanceof Element)) return 314 | const mdButton = target.closest('[data-md-button]') 315 | if (!mdButton || mdButton.closest('markdown-toolbar') !== currentTarget) return 316 | const mdButtonStyle = mdButton.getAttribute('data-md-button') 317 | const style = manualStyles[mdButtonStyle as keyof typeof manualStyles] 318 | if (!style) return 319 | event.preventDefault() 320 | applyStyle(target, style) 321 | } 322 | 323 | function setFocusManagement(toolbar: MarkdownToolbarElement) { 324 | toolbar.addEventListener('keydown', focusKeydown) 325 | toolbar.setAttribute('tabindex', '0') 326 | toolbar.addEventListener('focus', onToolbarFocus, {once: true}) 327 | } 328 | 329 | function unsetFocusManagement(toolbar: MarkdownToolbarElement) { 330 | toolbar.removeEventListener('keydown', focusKeydown) 331 | toolbar.removeAttribute('tabindex') 332 | toolbar.removeEventListener('focus', onToolbarFocus) 333 | } 334 | 335 | class MarkdownToolbarElement extends HTMLElement { 336 | static observedAttributes = ['data-no-focus'] 337 | 338 | connectedCallback(): void { 339 | if (!this.hasAttribute('role')) { 340 | this.setAttribute('role', 'toolbar') 341 | } 342 | if (!this.hasAttribute('data-no-focus')) { 343 | setFocusManagement(this) 344 | } 345 | this.addEventListener('keydown', keydown(applyFromToolbar)) 346 | this.addEventListener('click', applyFromToolbar) 347 | } 348 | 349 | attributeChangedCallback(name: string, oldValue: string, newValue: string): void { 350 | if (name !== 'data-no-focus') return 351 | if (newValue === null) { 352 | setFocusManagement(this) 353 | } else { 354 | unsetFocusManagement(this) 355 | } 356 | } 357 | 358 | disconnectedCallback(): void { 359 | unsetFocusManagement(this) 360 | } 361 | 362 | get field(): HTMLTextAreaElement | null { 363 | const id = this.getAttribute('for') 364 | if (!id) return null 365 | const root = 'getRootNode' in this ? this.getRootNode() : document 366 | let field 367 | if (root instanceof Document || root instanceof ShadowRoot) { 368 | field = root.getElementById(id) 369 | } 370 | return field instanceof HTMLTextAreaElement ? field : null 371 | } 372 | } 373 | 374 | function onToolbarFocus({target}: FocusEvent) { 375 | if (!(target instanceof Element)) return 376 | target.removeAttribute('tabindex') 377 | let tabindex = '0' 378 | for (const button of getButtons(target)) { 379 | button.setAttribute('tabindex', tabindex) 380 | if (tabindex === '0') { 381 | button.focus() 382 | tabindex = '-1' 383 | } 384 | } 385 | } 386 | 387 | function focusKeydown(event: KeyboardEvent) { 388 | const key = event.key 389 | if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return 390 | const toolbar = event.currentTarget 391 | if (!(toolbar instanceof HTMLElement)) return 392 | const buttons = getButtons(toolbar) 393 | const index = buttons.indexOf(event.target as HTMLElement) 394 | const length = buttons.length 395 | if (index === -1) return 396 | 397 | let n = 0 398 | if (key === 'ArrowLeft') n = index - 1 399 | if (key === 'ArrowRight') n = index + 1 400 | if (key === 'End') n = length - 1 401 | if (n < 0) n = length - 1 402 | if (n > length - 1) n = 0 403 | 404 | for (let i = 0; i < length; i += 1) { 405 | buttons[i].setAttribute('tabindex', i === n ? '0' : '-1') 406 | } 407 | 408 | // Need to stop home/end scrolling: 409 | event.preventDefault() 410 | 411 | buttons[n].focus() 412 | } 413 | 414 | if (!window.customElements.get('markdown-toolbar')) { 415 | window.MarkdownToolbarElement = MarkdownToolbarElement 416 | window.customElements.define('markdown-toolbar', MarkdownToolbarElement) 417 | } 418 | 419 | function isMultipleLines(string: string): boolean { 420 | return string.trim().split('\n').length > 1 421 | } 422 | 423 | function repeat(string: string, n: number): string { 424 | return Array(n + 1).join(string) 425 | } 426 | 427 | function wordSelectionStart(text: string, i: number): number { 428 | let index = i 429 | while (text[index] && text[index - 1] != null && !text[index - 1].match(/\s/)) { 430 | index-- 431 | } 432 | return index 433 | } 434 | 435 | function wordSelectionEnd(text: string, i: number, multiline: boolean): number { 436 | let index = i 437 | const breakpoint = multiline ? /\n/ : /\s/ 438 | while (text[index] && !text[index].match(breakpoint)) { 439 | index++ 440 | } 441 | return index 442 | } 443 | 444 | let canInsertText: boolean | null = null 445 | 446 | function insertText(textarea: HTMLTextAreaElement, {text, selectionStart, selectionEnd}: SelectionRange) { 447 | const originalSelectionStart = textarea.selectionStart 448 | const before = textarea.value.slice(0, originalSelectionStart) 449 | const after = textarea.value.slice(textarea.selectionEnd) 450 | 451 | if (canInsertText === null || canInsertText === true) { 452 | textarea.contentEditable = 'true' 453 | try { 454 | canInsertText = document.execCommand('insertText', false, text) 455 | } catch (error) { 456 | canInsertText = false 457 | } 458 | textarea.contentEditable = 'false' 459 | } 460 | 461 | if (canInsertText && !textarea.value.slice(0, textarea.selectionStart).endsWith(text)) { 462 | canInsertText = false 463 | } 464 | 465 | if (!canInsertText) { 466 | try { 467 | document.execCommand('ms-beginUndoUnit') 468 | } catch (e) { 469 | // Do nothing. 470 | } 471 | textarea.value = before + text + after 472 | try { 473 | document.execCommand('ms-endUndoUnit') 474 | } catch (e) { 475 | // Do nothing. 476 | } 477 | textarea.dispatchEvent(new CustomEvent('input', {bubbles: true, cancelable: true})) 478 | } 479 | 480 | if (selectionStart != null && selectionEnd != null) { 481 | textarea.setSelectionRange(selectionStart, selectionEnd) 482 | } else { 483 | textarea.setSelectionRange(originalSelectionStart, textarea.selectionEnd) 484 | } 485 | } 486 | 487 | function styleSelectedText(textarea: HTMLTextAreaElement, styleArgs: StyleArgs) { 488 | const text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 489 | 490 | let result 491 | if (styleArgs.orderedList || styleArgs.unorderedList) { 492 | result = listStyle(textarea, styleArgs) 493 | } else if (styleArgs.multiline && isMultipleLines(text)) { 494 | result = multilineStyle(textarea, styleArgs) 495 | } else { 496 | result = blockStyle(textarea, styleArgs) 497 | } 498 | 499 | insertText(textarea, result) 500 | } 501 | 502 | function expandSelectionToLine(textarea: HTMLTextAreaElement) { 503 | const lines = textarea.value.split('\n') 504 | let counter = 0 505 | for (let index = 0; index < lines.length; index++) { 506 | const lineLength = lines[index].length + 1 507 | if (textarea.selectionStart >= counter && textarea.selectionStart < counter + lineLength) { 508 | textarea.selectionStart = counter 509 | } 510 | if (textarea.selectionEnd >= counter && textarea.selectionEnd < counter + lineLength) { 511 | textarea.selectionEnd = counter + lineLength - 1 512 | } 513 | counter += lineLength 514 | } 515 | } 516 | 517 | function expandSelectedText( 518 | textarea: HTMLTextAreaElement, 519 | prefixToUse: string, 520 | suffixToUse: string, 521 | multiline = false 522 | ): string { 523 | if (textarea.selectionStart === textarea.selectionEnd) { 524 | textarea.selectionStart = wordSelectionStart(textarea.value, textarea.selectionStart) 525 | textarea.selectionEnd = wordSelectionEnd(textarea.value, textarea.selectionEnd, multiline) 526 | } else { 527 | const expandedSelectionStart = textarea.selectionStart - prefixToUse.length 528 | const expandedSelectionEnd = textarea.selectionEnd + suffixToUse.length 529 | const beginsWithPrefix = textarea.value.slice(expandedSelectionStart, textarea.selectionStart) === prefixToUse 530 | const endsWithSuffix = textarea.value.slice(textarea.selectionEnd, expandedSelectionEnd) === suffixToUse 531 | if (beginsWithPrefix && endsWithSuffix) { 532 | textarea.selectionStart = expandedSelectionStart 533 | textarea.selectionEnd = expandedSelectionEnd 534 | } 535 | } 536 | return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 537 | } 538 | 539 | interface Newlines { 540 | newlinesToAppend: string 541 | newlinesToPrepend: string 542 | } 543 | 544 | function newlinesToSurroundSelectedText(textarea: HTMLTextAreaElement): Newlines { 545 | const beforeSelection = textarea.value.slice(0, textarea.selectionStart) 546 | const afterSelection = textarea.value.slice(textarea.selectionEnd) 547 | 548 | const breaksBefore = beforeSelection.match(/\n*$/) 549 | const breaksAfter = afterSelection.match(/^\n*/) 550 | const newlinesBeforeSelection = breaksBefore ? breaksBefore[0].length : 0 551 | const newlinesAfterSelection = breaksAfter ? breaksAfter[0].length : 0 552 | 553 | let newlinesToAppend 554 | let newlinesToPrepend 555 | 556 | if (beforeSelection.match(/\S/) && newlinesBeforeSelection < 2) { 557 | newlinesToAppend = repeat('\n', 2 - newlinesBeforeSelection) 558 | } 559 | 560 | if (afterSelection.match(/\S/) && newlinesAfterSelection < 2) { 561 | newlinesToPrepend = repeat('\n', 2 - newlinesAfterSelection) 562 | } 563 | 564 | if (newlinesToAppend == null) { 565 | newlinesToAppend = '' 566 | } 567 | 568 | if (newlinesToPrepend == null) { 569 | newlinesToPrepend = '' 570 | } 571 | 572 | return {newlinesToAppend, newlinesToPrepend} 573 | } 574 | 575 | interface SelectionRange { 576 | text: string 577 | selectionStart: number | undefined 578 | selectionEnd: number | undefined 579 | } 580 | 581 | function blockStyle(textarea: HTMLTextAreaElement, arg: StyleArgs): SelectionRange { 582 | let newlinesToAppend 583 | let newlinesToPrepend 584 | 585 | const {prefix, suffix, blockPrefix, blockSuffix, replaceNext, prefixSpace, scanFor, surroundWithNewlines} = arg 586 | const originalSelectionStart = textarea.selectionStart 587 | const originalSelectionEnd = textarea.selectionEnd 588 | 589 | let selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 590 | let prefixToUse = isMultipleLines(selectedText) && blockPrefix.length > 0 ? `${blockPrefix}\n` : prefix 591 | let suffixToUse = isMultipleLines(selectedText) && blockSuffix.length > 0 ? `\n${blockSuffix}` : suffix 592 | 593 | if (prefixSpace) { 594 | const beforeSelection = textarea.value[textarea.selectionStart - 1] 595 | if (textarea.selectionStart !== 0 && beforeSelection != null && !beforeSelection.match(/\s/)) { 596 | prefixToUse = ` ${prefixToUse}` 597 | } 598 | } 599 | selectedText = expandSelectedText(textarea, prefixToUse, suffixToUse, arg.multiline) 600 | let selectionStart = textarea.selectionStart 601 | let selectionEnd = textarea.selectionEnd 602 | const hasReplaceNext = replaceNext.length > 0 && suffixToUse.indexOf(replaceNext) > -1 && selectedText.length > 0 603 | if (surroundWithNewlines) { 604 | const ref = newlinesToSurroundSelectedText(textarea) 605 | newlinesToAppend = ref.newlinesToAppend 606 | newlinesToPrepend = ref.newlinesToPrepend 607 | prefixToUse = newlinesToAppend + prefix 608 | suffixToUse += newlinesToPrepend 609 | } 610 | 611 | if (selectedText.startsWith(prefixToUse) && selectedText.endsWith(suffixToUse)) { 612 | const replacementText = selectedText.slice(prefixToUse.length, selectedText.length - suffixToUse.length) 613 | if (originalSelectionStart === originalSelectionEnd) { 614 | let position = originalSelectionStart - prefixToUse.length 615 | position = Math.max(position, selectionStart) 616 | position = Math.min(position, selectionStart + replacementText.length) 617 | selectionStart = selectionEnd = position 618 | } else { 619 | selectionEnd = selectionStart + replacementText.length 620 | } 621 | return {text: replacementText, selectionStart, selectionEnd} 622 | } else if (!hasReplaceNext) { 623 | let replacementText = prefixToUse + selectedText + suffixToUse 624 | selectionStart = originalSelectionStart + prefixToUse.length 625 | selectionEnd = originalSelectionEnd + prefixToUse.length 626 | const whitespaceEdges = selectedText.match(/^\s*|\s*$/g) 627 | if (arg.trimFirst && whitespaceEdges) { 628 | const leadingWhitespace = whitespaceEdges[0] || '' 629 | const trailingWhitespace = whitespaceEdges[1] || '' 630 | replacementText = leadingWhitespace + prefixToUse + selectedText.trim() + suffixToUse + trailingWhitespace 631 | selectionStart += leadingWhitespace.length 632 | selectionEnd -= trailingWhitespace.length 633 | } 634 | return {text: replacementText, selectionStart, selectionEnd} 635 | } else if (scanFor.length > 0 && selectedText.match(scanFor)) { 636 | suffixToUse = suffixToUse.replace(replaceNext, selectedText) 637 | const replacementText = prefixToUse + suffixToUse 638 | selectionStart = selectionEnd = selectionStart + prefixToUse.length 639 | return {text: replacementText, selectionStart, selectionEnd} 640 | } else { 641 | const replacementText = prefixToUse + selectedText + suffixToUse 642 | selectionStart = selectionStart + prefixToUse.length + selectedText.length + suffixToUse.indexOf(replaceNext) 643 | selectionEnd = selectionStart + replaceNext.length 644 | return {text: replacementText, selectionStart, selectionEnd} 645 | } 646 | } 647 | 648 | function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) { 649 | const {prefix, suffix, surroundWithNewlines} = arg 650 | let text = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 651 | let selectionStart = textarea.selectionStart 652 | let selectionEnd = textarea.selectionEnd 653 | const lines = text.split('\n') 654 | const undoStyle = lines.every(line => line.startsWith(prefix) && line.endsWith(suffix)) 655 | 656 | if (undoStyle) { 657 | text = lines.map(line => line.slice(prefix.length, line.length - suffix.length)).join('\n') 658 | selectionEnd = selectionStart + text.length 659 | } else { 660 | text = lines.map(line => prefix + line + suffix).join('\n') 661 | if (surroundWithNewlines) { 662 | const {newlinesToAppend, newlinesToPrepend} = newlinesToSurroundSelectedText(textarea) 663 | selectionStart += newlinesToAppend.length 664 | selectionEnd = selectionStart + text.length 665 | text = newlinesToAppend + text + newlinesToPrepend 666 | } 667 | } 668 | 669 | return {text, selectionStart, selectionEnd} 670 | } 671 | 672 | interface UndoResult { 673 | text: string 674 | processed: boolean 675 | } 676 | function undoOrderedListStyle(text: string): UndoResult { 677 | const lines = text.split('\n') 678 | const orderedListRegex = /^\d+\.\s+/ 679 | const shouldUndoOrderedList = lines.every(line => orderedListRegex.test(line)) 680 | let result = lines 681 | if (shouldUndoOrderedList) { 682 | result = lines.map(line => line.replace(orderedListRegex, '')) 683 | } 684 | 685 | return { 686 | text: result.join('\n'), 687 | processed: shouldUndoOrderedList 688 | } 689 | } 690 | 691 | function undoUnorderedListStyle(text: string): UndoResult { 692 | const lines = text.split('\n') 693 | const unorderedListPrefix = '- ' 694 | const shouldUndoUnorderedList = lines.every(line => line.startsWith(unorderedListPrefix)) 695 | let result = lines 696 | if (shouldUndoUnorderedList) { 697 | result = lines.map(line => line.slice(unorderedListPrefix.length, line.length)) 698 | } 699 | 700 | return { 701 | text: result.join('\n'), 702 | processed: shouldUndoUnorderedList 703 | } 704 | } 705 | 706 | function makePrefix(index: number, unorderedList: boolean): string { 707 | if (unorderedList) { 708 | return '- ' 709 | } else { 710 | return `${index + 1}. ` 711 | } 712 | } 713 | 714 | function clearExistingListStyle(style: StyleArgs, selectedText: string): [UndoResult, UndoResult, string] { 715 | let undoResultOpositeList: UndoResult 716 | let undoResult: UndoResult 717 | let pristineText 718 | if (style.orderedList) { 719 | undoResult = undoOrderedListStyle(selectedText) 720 | undoResultOpositeList = undoUnorderedListStyle(undoResult.text) 721 | pristineText = undoResultOpositeList.text 722 | } else { 723 | undoResult = undoUnorderedListStyle(selectedText) 724 | undoResultOpositeList = undoOrderedListStyle(undoResult.text) 725 | pristineText = undoResultOpositeList.text 726 | } 727 | return [undoResult, undoResultOpositeList, pristineText] 728 | } 729 | 730 | function listStyle(textarea: HTMLTextAreaElement, style: StyleArgs): SelectionRange { 731 | const noInitialSelection = textarea.selectionStart === textarea.selectionEnd 732 | let selectionStart = textarea.selectionStart 733 | let selectionEnd = textarea.selectionEnd 734 | 735 | // Select whole line 736 | expandSelectionToLine(textarea) 737 | 738 | const selectedText = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 739 | 740 | // If the user intent was to do an undo, we will stop after this. 741 | // Otherwise, we will still undo to other list type to prevent list stacking 742 | const [undoResult, undoResultOpositeList, pristineText] = clearExistingListStyle(style, selectedText) 743 | 744 | const prefixedLines = pristineText.split('\n').map((value, index) => { 745 | return `${makePrefix(index, style.unorderedList)}${value}` 746 | }) 747 | 748 | const totalPrefixLength = prefixedLines.reduce((previousValue, _currentValue, currentIndex) => { 749 | return previousValue + makePrefix(currentIndex, style.unorderedList).length 750 | }, 0) 751 | 752 | const totalPrefixLengthOpositeList = prefixedLines.reduce((previousValue, _currentValue, currentIndex) => { 753 | return previousValue + makePrefix(currentIndex, !style.unorderedList).length 754 | }, 0) 755 | 756 | if (undoResult.processed) { 757 | if (noInitialSelection) { 758 | selectionStart = Math.max(selectionStart - makePrefix(0, style.unorderedList).length, 0) 759 | selectionEnd = selectionStart 760 | } else { 761 | selectionStart = textarea.selectionStart 762 | selectionEnd = textarea.selectionEnd - totalPrefixLength 763 | } 764 | return {text: pristineText, selectionStart, selectionEnd} 765 | } 766 | 767 | const {newlinesToAppend, newlinesToPrepend} = newlinesToSurroundSelectedText(textarea) 768 | const text = newlinesToAppend + prefixedLines.join('\n') + newlinesToPrepend 769 | 770 | if (noInitialSelection) { 771 | selectionStart = Math.max(selectionStart + makePrefix(0, style.unorderedList).length + newlinesToAppend.length, 0) 772 | selectionEnd = selectionStart 773 | } else { 774 | if (undoResultOpositeList.processed) { 775 | selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0) 776 | selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength - totalPrefixLengthOpositeList 777 | } else { 778 | selectionStart = Math.max(textarea.selectionStart + newlinesToAppend.length, 0) 779 | selectionEnd = textarea.selectionEnd + newlinesToAppend.length + totalPrefixLength 780 | } 781 | } 782 | 783 | return {text, selectionStart, selectionEnd} 784 | } 785 | 786 | interface StyleArgs { 787 | prefix: string 788 | suffix: string 789 | blockPrefix: string 790 | blockSuffix: string 791 | multiline: boolean 792 | replaceNext: string 793 | prefixSpace: boolean 794 | scanFor: string 795 | surroundWithNewlines: boolean 796 | orderedList: boolean 797 | unorderedList: boolean 798 | trimFirst: boolean 799 | } 800 | 801 | function applyStyle(button: Element, stylesToApply: Style) { 802 | const toolbar = button.closest('markdown-toolbar') 803 | if (!(toolbar instanceof MarkdownToolbarElement)) return 804 | 805 | const defaults = { 806 | prefix: '', 807 | suffix: '', 808 | blockPrefix: '', 809 | blockSuffix: '', 810 | multiline: false, 811 | replaceNext: '', 812 | prefixSpace: false, 813 | scanFor: '', 814 | surroundWithNewlines: false, 815 | orderedList: false, 816 | unorderedList: false, 817 | trimFirst: false 818 | } 819 | 820 | const style = {...defaults, ...stylesToApply} 821 | 822 | const field = toolbar.field 823 | if (field) { 824 | field.focus() 825 | styleSelectedText(field, style) 826 | } 827 | } 828 | 829 | export default MarkdownToolbarElement 830 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "espree", 3 | "env": { 4 | "mocha": true 5 | }, 6 | "globals": { 7 | "assert": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/karma.config.cjs: -------------------------------------------------------------------------------- 1 | process.env.CHROME_BIN = require('chromium').path 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | frameworks: ['mocha', 'chai'], 6 | files: [{ pattern: '../dist/index.js', type: 'module' }, 'test.js'], 7 | reporters: ['mocha'], 8 | port: 9876, 9 | colors: true, 10 | logLevel: config.LOG_INFO, 11 | browsers: ['ChromeHeadless'], 12 | autoWatch: false, 13 | singleRun: true, 14 | concurrency: Infinity 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | describe('markdown-toolbar-element', function () { 2 | describe('element creation', function () { 3 | it('creates from document.createElement', function () { 4 | const el = document.createElement('markdown-toolbar') 5 | assert.equal('MARKDOWN-TOOLBAR', el.nodeName) 6 | }) 7 | 8 | it('creates from constructor', function () { 9 | const el = new window.MarkdownToolbarElement() 10 | assert.equal('MARKDOWN-TOOLBAR', el.nodeName) 11 | }) 12 | }) 13 | 14 | describe('in shadow DOM', function () { 15 | it('finds field and inserts markdown', function () { 16 | const div = document.createElement('div') 17 | const shadow = div.attachShadow({mode: 'open'}) 18 | shadow.innerHTML = `bold` 19 | document.body.append(div) 20 | 21 | const toolbar = shadow.querySelector('markdown-toolbar') 22 | assert(toolbar.field, 'textarea is found') 23 | 24 | toolbar.querySelector('md-bold').click() 25 | assert(toolbar.field.value, '****') 26 | }) 27 | }) 28 | 29 | describe('after tree insertion', function () { 30 | function clickToolbar(selector) { 31 | const toolbar = document.querySelector('markdown-toolbar') 32 | toolbar.querySelector(selector).click() 33 | } 34 | 35 | function visualValue() { 36 | const textarea = document.querySelector('textarea') 37 | const before = textarea.value.slice(0, textarea.selectionStart) 38 | const selection = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd) 39 | const after = textarea.value.slice(textarea.selectionEnd) 40 | if (selection) { 41 | return `${before}|${selection}|${after}` 42 | } else { 43 | return `${before}|${after}` 44 | } 45 | } 46 | 47 | function setVisualValue(value) { 48 | const textarea = document.querySelector('textarea') 49 | let idx 50 | const parts = value.split('|', 3) 51 | textarea.value = parts.join('') 52 | switch (parts.length) { 53 | case 2: 54 | idx = parts[0].length 55 | textarea.setSelectionRange(idx, idx) 56 | break 57 | case 3: 58 | idx = parts[0].length 59 | textarea.setSelectionRange(idx, idx + parts[1].length) 60 | break 61 | } 62 | } 63 | 64 | beforeEach(function () { 65 | const container = document.createElement('div') 66 | container.innerHTML = ` 67 | 68 | bold 69 | header 70 | h1 71 | 74 | h1 75 |
Other button
76 | italic 77 | strikethrough 78 | quote 79 | code 80 | link 81 | image 82 | unordered-list 83 | ordered-list 84 | task-list 85 | mention 86 | ref 87 |
88 | 89 | ` 90 | document.body.append(container) 91 | }) 92 | 93 | afterEach(function () { 94 | document.body.innerHTML = '' 95 | }) 96 | 97 | describe('focus management', function () { 98 | function focusFirstButton() { 99 | document.querySelector('markdown-toolbar').focus() 100 | const button = document.querySelector('md-bold') 101 | button.focus() 102 | } 103 | 104 | function pushKeyOnFocussedButton(key) { 105 | const event = document.createEvent('Event') 106 | event.initEvent('keydown', true, true) 107 | event.key = key 108 | document.activeElement.dispatchEvent(event) 109 | } 110 | 111 | function getElementsWithTabindex(index) { 112 | return [...document.querySelectorAll(`markdown-toolbar [tabindex="${index}"]`)] 113 | } 114 | 115 | it('moves focus to next button when ArrowRight is pressed', function () { 116 | focusFirstButton() 117 | pushKeyOnFocussedButton('ArrowRight') 118 | assert.equal(getElementsWithTabindex(-1).length, 15) 119 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header')]) 120 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 121 | pushKeyOnFocussedButton('ArrowRight') 122 | assert.equal(getElementsWithTabindex(-1).length, 15) 123 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="1"]')]) 124 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 125 | pushKeyOnFocussedButton('ArrowRight') 126 | assert.equal(getElementsWithTabindex(-1).length, 15) 127 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-header[level="10"]')]) 128 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 129 | }) 130 | 131 | it('does not move focus if `data-no-focus` is present', function () { 132 | document.querySelector('markdown-toolbar').setAttribute('data-no-focus', '') 133 | focusFirstButton() 134 | pushKeyOnFocussedButton('ArrowRight') 135 | assert.lengthOf(getElementsWithTabindex(0), 0) 136 | }) 137 | 138 | it('cycles focus round to last element from first when ArrowLeft is pressed', function () { 139 | focusFirstButton() 140 | pushKeyOnFocussedButton('ArrowLeft') 141 | assert.equal(getElementsWithTabindex(-1).length, 15) 142 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')]) 143 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 144 | pushKeyOnFocussedButton('ArrowLeft') 145 | assert.equal(getElementsWithTabindex(-1).length, 15) 146 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-mention')]) 147 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 148 | }) 149 | 150 | it('focussed first/last button when Home/End key is pressed', function () { 151 | focusFirstButton() 152 | pushKeyOnFocussedButton('End') 153 | assert.equal(getElementsWithTabindex(-1).length, 15) 154 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')]) 155 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 156 | pushKeyOnFocussedButton('End') 157 | assert.equal(getElementsWithTabindex(-1).length, 15) 158 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-ref')]) 159 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 160 | pushKeyOnFocussedButton('Home') 161 | assert.equal(getElementsWithTabindex(-1).length, 15) 162 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')]) 163 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 164 | pushKeyOnFocussedButton('Home') 165 | assert.equal(getElementsWithTabindex(-1).length, 15) 166 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('md-bold')]) 167 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 168 | }) 169 | 170 | it('counts `data-md-button` elements in the focussable set', function () { 171 | focusFirstButton() 172 | pushKeyOnFocussedButton('ArrowRight') 173 | pushKeyOnFocussedButton('ArrowRight') 174 | pushKeyOnFocussedButton('ArrowRight') 175 | pushKeyOnFocussedButton('ArrowRight') 176 | assert.equal(getElementsWithTabindex(-1).length, 15) 177 | assert.deepEqual(getElementsWithTabindex(0), [document.querySelector('div[data-md-button]')]) 178 | assert.deepEqual(getElementsWithTabindex(0), [document.activeElement]) 179 | }) 180 | }) 181 | 182 | describe('bold', function () { 183 | it('bold selected text when you click the bold icon', function () { 184 | setVisualValue('The |quick| brown fox jumps over the lazy dog') 185 | clickToolbar('md-bold') 186 | assert.equal('The **|quick|** brown fox jumps over the lazy dog', visualValue()) 187 | }) 188 | 189 | it('bold empty selection and textarea inserts ** with cursor ready to type inside', function () { 190 | setVisualValue('|') 191 | clickToolbar('md-bold') 192 | assert.equal('**|**', visualValue()) 193 | }) 194 | 195 | it('bold empty selection with previous text inserts ** with cursor ready to type inside', function () { 196 | setVisualValue('The |') 197 | clickToolbar('md-bold') 198 | assert.equal('The **|**', visualValue()) 199 | }) 200 | 201 | it('bold when there is leading whitespace in selection', function () { 202 | setVisualValue('|\n \t Hello world|') 203 | clickToolbar('md-bold') 204 | assert.equal('\n \t **|Hello world|**', visualValue()) 205 | }) 206 | 207 | it('bold when there is trailing whitespace in selection', function () { 208 | setVisualValue('|Hello world \n|') 209 | clickToolbar('md-bold') 210 | assert.equal('**|Hello world|** \n', visualValue()) 211 | }) 212 | 213 | it('bold selected word when cursor is at the start of the word', function () { 214 | setVisualValue('The |quick brown fox jumps over the lazy dog') 215 | clickToolbar('md-bold') 216 | assert.equal('The **|quick** brown fox jumps over the lazy dog', visualValue()) 217 | }) 218 | 219 | it('bold selected word when cursor is in the middle of the word', function () { 220 | setVisualValue('The qui|ck brown fox jumps over the lazy dog') 221 | clickToolbar('md-bold') 222 | assert.equal('The **qui|ck** brown fox jumps over the lazy dog', visualValue()) 223 | }) 224 | 225 | it('bold selected word when cursor is at the end of the word', function () { 226 | setVisualValue('The quick| brown fox jumps over the lazy dog') 227 | clickToolbar('md-bold') 228 | assert.equal('The **quick|** brown fox jumps over the lazy dog', visualValue()) 229 | }) 230 | 231 | it('bold selected word when cursor is at the start of the first word', function () { 232 | setVisualValue('|The quick brown fox jumps over the lazy dog') 233 | clickToolbar('md-bold') 234 | assert.equal('**|The** quick brown fox jumps over the lazy dog', visualValue()) 235 | }) 236 | 237 | it('bold selected word when cursor is in the middle of the first word', function () { 238 | setVisualValue('T|he quick brown fox jumps over the lazy dog') 239 | clickToolbar('md-bold') 240 | assert.equal('**T|he** quick brown fox jumps over the lazy dog', visualValue()) 241 | }) 242 | 243 | it('bold selected word when cursor is at the end of the first word', function () { 244 | setVisualValue('The| quick brown fox jumps over the lazy dog') 245 | clickToolbar('md-bold') 246 | assert.equal('**The|** quick brown fox jumps over the lazy dog', visualValue()) 247 | }) 248 | 249 | it('unbolds selected bold inner text when you click the bold icon', function () { 250 | setVisualValue('The **|quick|** brown fox jumps over the lazy dog') 251 | clickToolbar('md-bold') 252 | assert.equal('The |quick| brown fox jumps over the lazy dog', visualValue()) 253 | }) 254 | 255 | it('unbolds selected bold outer text when you click the bold icon', function () { 256 | setVisualValue('The |**quick**| brown fox jumps over the lazy dog') 257 | clickToolbar('md-bold') 258 | assert.equal('The |quick| brown fox jumps over the lazy dog', visualValue()) 259 | }) 260 | 261 | it('unbold selected word when cursor is at the start of the word', function () { 262 | setVisualValue('The **|quick** brown fox jumps over the lazy dog') 263 | clickToolbar('md-bold') 264 | assert.equal('The |quick brown fox jumps over the lazy dog', visualValue()) 265 | }) 266 | 267 | it('unbold selected word when cursor is in the middle of the word', function () { 268 | setVisualValue('The **qui|ck** brown fox jumps over the lazy dog') 269 | clickToolbar('md-bold') 270 | assert.equal('The qui|ck brown fox jumps over the lazy dog', visualValue()) 271 | }) 272 | 273 | it('unbold selected word when cursor is at the end of the word', function () { 274 | setVisualValue('The **quick|** brown fox jumps over the lazy dog') 275 | clickToolbar('md-bold') 276 | assert.equal('The quick| brown fox jumps over the lazy dog', visualValue()) 277 | }) 278 | 279 | it('unbold selected word when cursor is before the bold syntax', function () { 280 | setVisualValue('The |**quick** brown fox jumps over the lazy dog') 281 | clickToolbar('md-bold') 282 | assert.equal('The |quick brown fox jumps over the lazy dog', visualValue()) 283 | }) 284 | 285 | it('unbold selected word when cursor is after the bold syntax', function () { 286 | setVisualValue('The **quick**| brown fox jumps over the lazy dog') 287 | clickToolbar('md-bold') 288 | assert.equal('The quick| brown fox jumps over the lazy dog', visualValue()) 289 | }) 290 | 291 | it('unbold selected word when cursor is at the start of the first word', function () { 292 | setVisualValue('**|The** quick brown fox jumps over the lazy dog') 293 | clickToolbar('md-bold') 294 | assert.equal('|The quick brown fox jumps over the lazy dog', visualValue()) 295 | }) 296 | 297 | it('unbold selected word when cursor is in the middle of the first word', function () { 298 | setVisualValue('**T|he** quick brown fox jumps over the lazy dog') 299 | clickToolbar('md-bold') 300 | assert.equal('T|he quick brown fox jumps over the lazy dog', visualValue()) 301 | }) 302 | 303 | it('unbold selected word when cursor is at the end of the first word', function () { 304 | setVisualValue('**The|** quick brown fox jumps over the lazy dog') 305 | clickToolbar('md-bold') 306 | assert.equal('The| quick brown fox jumps over the lazy dog', visualValue()) 307 | }) 308 | }) 309 | 310 | describe('italic', function () { 311 | it('italicizes selected text when you click the italics icon', function () { 312 | setVisualValue('The |quick| brown fox jumps over the lazy dog') 313 | clickToolbar('md-italic') 314 | assert.equal('The _|quick|_ brown fox jumps over the lazy dog', visualValue()) 315 | }) 316 | 317 | it('italicize when there is leading whitespace in selection', function () { 318 | setVisualValue('| \nHello world|') 319 | clickToolbar('md-italic') 320 | assert.equal(' \n_|Hello world|_', visualValue()) 321 | }) 322 | 323 | it('italicize when there is trailing whitespace in selection', function () { 324 | setVisualValue('|Hello world\n \t|') 325 | clickToolbar('md-italic') 326 | assert.equal('_|Hello world|_\n \t', visualValue()) 327 | }) 328 | 329 | it('italicize empty selection and textarea inserts * with cursor ready to type inside', function () { 330 | setVisualValue('|') 331 | clickToolbar('md-italic') 332 | assert.equal('_|_', visualValue()) 333 | }) 334 | 335 | it('italicize empty selection with previous text inserts * with cursor ready to type inside', function () { 336 | setVisualValue('The |') 337 | clickToolbar('md-italic') 338 | assert.equal('The _|_', visualValue()) 339 | }) 340 | 341 | it('italicize selected word when cursor is at the start of the word', function () { 342 | setVisualValue('The |quick brown fox jumps over the lazy dog') 343 | clickToolbar('md-italic') 344 | assert.equal('The _|quick_ brown fox jumps over the lazy dog', visualValue()) 345 | }) 346 | 347 | it('italicize selected word when cursor is in the middle of the word', function () { 348 | setVisualValue('The qui|ck brown fox jumps over the lazy dog') 349 | clickToolbar('md-italic') 350 | assert.equal('The _qui|ck_ brown fox jumps over the lazy dog', visualValue()) 351 | }) 352 | 353 | it('italicize selected word when cursor is at the end of the word', function () { 354 | setVisualValue('The quick| brown fox jumps over the lazy dog') 355 | clickToolbar('md-italic') 356 | assert.equal('The _quick|_ brown fox jumps over the lazy dog', visualValue()) 357 | }) 358 | 359 | it('unitalicizes selected italic text when you click the italic icon', function () { 360 | setVisualValue('The _|quick|_ brown fox jumps over the lazy dog') 361 | clickToolbar('md-italic') 362 | assert.equal('The |quick| brown fox jumps over the lazy dog', visualValue()) 363 | }) 364 | }) 365 | 366 | describe('strikethrough', function () { 367 | it('strikes through selected text when you click the strikethrough icon', function () { 368 | setVisualValue('The |quick| brown fox jumps over the lazy dog') 369 | clickToolbar('md-strikethrough') 370 | assert.equal('The ~~|quick|~~ brown fox jumps over the lazy dog', visualValue()) 371 | }) 372 | 373 | it('strikes through when there is leading whitespace in selection', function () { 374 | setVisualValue('| \nHello world|') 375 | clickToolbar('md-strikethrough') 376 | assert.equal(' \n~~|Hello world|~~', visualValue()) 377 | }) 378 | 379 | it('strikes through when there is trailing whitespace in selection', function () { 380 | setVisualValue('|Hello world\n \t|') 381 | clickToolbar('md-strikethrough') 382 | assert.equal('~~|Hello world|~~\n \t', visualValue()) 383 | }) 384 | 385 | it('strikes through empty selection and textarea inserts ~~ with cursor ready to type inside', function () { 386 | setVisualValue('|') 387 | clickToolbar('md-strikethrough') 388 | assert.equal('~~|~~', visualValue()) 389 | }) 390 | 391 | it('strikes through empty selection with previous text inserts ~~ with cursor ready to type inside', function () { 392 | setVisualValue('The |') 393 | clickToolbar('md-strikethrough') 394 | assert.equal('The ~~|~~', visualValue()) 395 | }) 396 | 397 | it('strikes through selected word when cursor is at the start of the word', function () { 398 | setVisualValue('The |quick brown fox jumps over the lazy dog') 399 | clickToolbar('md-strikethrough') 400 | assert.equal('The ~~|quick~~ brown fox jumps over the lazy dog', visualValue()) 401 | }) 402 | 403 | it('strikes through selected word when cursor is in the middle of the word', function () { 404 | setVisualValue('The qui|ck brown fox jumps over the lazy dog') 405 | clickToolbar('md-strikethrough') 406 | assert.equal('The ~~qui|ck~~ brown fox jumps over the lazy dog', visualValue()) 407 | }) 408 | 409 | it('strikes through selected word when cursor is at the end of the word', function () { 410 | setVisualValue('The quick| brown fox jumps over the lazy dog') 411 | clickToolbar('md-strikethrough') 412 | assert.equal('The ~~quick|~~ brown fox jumps over the lazy dog', visualValue()) 413 | }) 414 | 415 | it('un-strikes through selected struck-through text when you click the strikethrough icon', function () { 416 | setVisualValue('The ~~|quick|~~ brown fox jumps over the lazy dog') 417 | clickToolbar('md-strikethrough') 418 | assert.equal('The |quick| brown fox jumps over the lazy dog', visualValue()) 419 | }) 420 | }) 421 | 422 | describe('quote level', function () { 423 | it('inserts selected quoted sample if you click the quote icon', function () { 424 | setVisualValue('') 425 | clickToolbar('md-quote') 426 | assert.equal('> |', visualValue()) 427 | }) 428 | 429 | it('quotes the selected text when you click the quote icon', function () { 430 | setVisualValue('|Butts|\n\nThe quick brown fox jumps over the lazy dog') 431 | clickToolbar('md-quote') 432 | assert.equal('> |Butts|\n\nThe quick brown fox jumps over the lazy dog', visualValue()) 433 | }) 434 | 435 | it('quotes full line of text when you click the quote icon', function () { 436 | setVisualValue('|The quick brown fox jumps over the lazy dog') 437 | clickToolbar('md-quote') 438 | assert.equal('> |The quick brown fox jumps over the lazy dog', visualValue()) 439 | }) 440 | 441 | it('prefixes newlines when quoting an existing line on an existing', function () { 442 | setVisualValue('The quick brown fox jumps over the lazy dog|Butts|') 443 | clickToolbar('md-quote') 444 | assert.equal('The quick brown fox jumps over the lazy dog\n\n> |Butts|', visualValue()) 445 | }) 446 | 447 | it('quotes multiple lines when you click the quote icon', function () { 448 | setVisualValue('|Hey,\n\nThis looks great.\n\nThanks,\nJosh|\n\nEmailed me that last week.') 449 | clickToolbar('md-quote') 450 | assert.equal( 451 | '|> Hey,\n> \n> This looks great.\n> \n> Thanks,\n> Josh|\n\nEmailed me that last week.'.replace( 452 | //g, 453 | '' 454 | ), 455 | visualValue() 456 | ) 457 | }) 458 | 459 | it('unquotes multiple lines when you click the quote icon', function () { 460 | setVisualValue( 461 | '|> Hey,\n> \n> This looks great.\n> \n> Thanks,\n> Josh|\n\nEmailed me that last week.'.replace( 462 | //g, 463 | '' 464 | ) 465 | ) 466 | clickToolbar('md-quote') 467 | assert.equal('|Hey,\n\nThis looks great.\n\nThanks,\nJosh|\n\nEmailed me that last week.', visualValue()) 468 | }) 469 | }) 470 | 471 | describe('mention', function () { 472 | it('inserts @ into an empty text area if you click the mention icon', function () { 473 | setVisualValue('') 474 | clickToolbar('md-mention') 475 | assert.equal('@|', visualValue()) 476 | }) 477 | 478 | it('inserts a space before the @ if there is not one before it', function () { 479 | setVisualValue('butts|') 480 | clickToolbar('md-mention') 481 | assert.equal('butts @|', visualValue()) 482 | }) 483 | 484 | it('treats any white space like a space', function () { 485 | setVisualValue('butts\n|') 486 | clickToolbar('md-mention') 487 | assert.equal('butts\n@|', visualValue()) 488 | }) 489 | }) 490 | 491 | describe('ordered list', function () { 492 | it('turns line into list if cursor at end of line', function () { 493 | setVisualValue('One\nTwo|\nThree\n') 494 | clickToolbar('md-ordered-list') 495 | assert.equal('One\n\n1. Two|\n\nThree\n', visualValue()) 496 | }) 497 | 498 | it('turns line into list if cursor at end of document', function () { 499 | setVisualValue('One\nTwo\nThree|') 500 | clickToolbar('md-ordered-list') 501 | assert.equal('One\nTwo\n\n1. Three|', visualValue()) 502 | }) 503 | 504 | it('turns line into list if cursor at beginning of line', function () { 505 | setVisualValue('One\n|Two\nThree\n') 506 | clickToolbar('md-ordered-list') 507 | assert.equal('One\n\n1. |Two\n\nThree\n', visualValue()) 508 | }) 509 | 510 | it('turns line into list if cursor at middle of line', function () { 511 | setVisualValue('One\nT|wo\nThree\n') 512 | clickToolbar('md-ordered-list') 513 | assert.equal('One\n\n1. T|wo\n\nThree\n', visualValue()) 514 | }) 515 | 516 | it('turns line into list if partial line is selected', function () { 517 | setVisualValue('One\nT|w|o\nThree\n') 518 | clickToolbar('md-ordered-list') 519 | assert.equal('One\n\n|1. Two|\n\nThree\n', visualValue()) 520 | }) 521 | 522 | it('turns two lines into list if two lines are selected', function () { 523 | setVisualValue('|One\nTwo|\nThree\n') 524 | clickToolbar('md-ordered-list') 525 | assert.equal('|1. One\n2. Two|\n\nThree\n', visualValue()) 526 | }) 527 | 528 | it('turns two lines into list if 2 lines are partially selected', function () { 529 | setVisualValue('O|ne\nTw|o\nThree\n') 530 | clickToolbar('md-ordered-list') 531 | assert.equal('|1. One\n2. Two|\n\nThree\n', visualValue()) 532 | }) 533 | 534 | it('undo list if cursor at end of line', function () { 535 | setVisualValue('One\n\n1. Two|\n\nThree\n') 536 | clickToolbar('md-ordered-list') 537 | assert.equal('One\n\nTwo|\n\nThree\n', visualValue()) 538 | }) 539 | 540 | it('undo list if cursor at end of document', function () { 541 | setVisualValue('One\nTwo\n\n1. Three|') 542 | clickToolbar('md-ordered-list') 543 | assert.equal('One\nTwo\n\nThree|', visualValue()) 544 | }) 545 | 546 | it('undo list if cursor at beginning of line', function () { 547 | setVisualValue('One\n\n1. |Two\n\nThree\n') 548 | clickToolbar('md-ordered-list') 549 | assert.equal('One\n\n|Two\n\nThree\n', visualValue()) 550 | }) 551 | 552 | it('undo list if cursor at middle of line', function () { 553 | setVisualValue('One\n\n1. T|wo\n\nThree\n') 554 | clickToolbar('md-ordered-list') 555 | assert.equal('One\n\nT|wo\n\nThree\n', visualValue()) 556 | }) 557 | 558 | it('undo list if partial line is selected', function () { 559 | setVisualValue('One\n\n1. T|w|o\n\nThree\n') 560 | clickToolbar('md-ordered-list') 561 | assert.equal('One\n\n|Two|\n\nThree\n', visualValue()) 562 | }) 563 | 564 | it('undo two lines list if two lines are selected', function () { 565 | setVisualValue('|1. One\n2. Two|\n\nThree\n') 566 | clickToolbar('md-ordered-list') 567 | assert.equal('|One\nTwo|\n\nThree\n', visualValue()) 568 | }) 569 | 570 | it('undo two lines list if 2 lines are partially selected', function () { 571 | setVisualValue('1. O|ne\n2. Tw|o\n\nThree\n') 572 | clickToolbar('md-ordered-list') 573 | assert.equal('|One\nTwo|\n\nThree\n', visualValue()) 574 | }) 575 | }) 576 | 577 | describe('unordered list', function () { 578 | it('turns line into list if cursor at end of line', function () { 579 | setVisualValue('One\nTwo|\nThree\n') 580 | clickToolbar('md-unordered-list') 581 | assert.equal('One\n\n- Two|\n\nThree\n', visualValue()) 582 | }) 583 | 584 | it('turns line into list if cursor at end of document', function () { 585 | setVisualValue('One\nTwo\nThree|') 586 | clickToolbar('md-unordered-list') 587 | assert.equal('One\nTwo\n\n- Three|', visualValue()) 588 | }) 589 | 590 | it('turns line into list if cursor at beginning of line', function () { 591 | setVisualValue('One\n|Two\nThree\n') 592 | clickToolbar('md-unordered-list') 593 | assert.equal('One\n\n- |Two\n\nThree\n', visualValue()) 594 | }) 595 | 596 | it('turns line into list if cursor at middle of line', function () { 597 | setVisualValue('One\nT|wo\nThree\n') 598 | clickToolbar('md-unordered-list') 599 | assert.equal('One\n\n- T|wo\n\nThree\n', visualValue()) 600 | }) 601 | 602 | it('turns line into list if partial line is selected', function () { 603 | setVisualValue('One\nT|w|o\nThree\n') 604 | clickToolbar('md-unordered-list') 605 | assert.equal('One\n\n|- Two|\n\nThree\n', visualValue()) 606 | }) 607 | 608 | it('turns two lines into list if two lines are selected', function () { 609 | setVisualValue('|One\nTwo|\nThree\n') 610 | clickToolbar('md-unordered-list') 611 | assert.equal('|- One\n- Two|\n\nThree\n', visualValue()) 612 | }) 613 | 614 | it('turns two lines into list if 2 lines are partially selected', function () { 615 | setVisualValue('O|ne\nTw|o\nThree\n') 616 | clickToolbar('md-unordered-list') 617 | assert.equal('|- One\n- Two|\n\nThree\n', visualValue()) 618 | }) 619 | 620 | it('undo list if cursor at end of line', function () { 621 | setVisualValue('One\n\n- Two|\n\nThree\n') 622 | clickToolbar('md-unordered-list') 623 | assert.equal('One\n\nTwo|\n\nThree\n', visualValue()) 624 | }) 625 | 626 | it('undo list if cursor at end of document', function () { 627 | setVisualValue('One\nTwo\n\n- Three|') 628 | clickToolbar('md-unordered-list') 629 | assert.equal('One\nTwo\n\nThree|', visualValue()) 630 | }) 631 | 632 | it('undo list if cursor at beginning of line', function () { 633 | setVisualValue('One\n\n- |Two\n\nThree\n') 634 | clickToolbar('md-unordered-list') 635 | assert.equal('One\n\n|Two\n\nThree\n', visualValue()) 636 | }) 637 | 638 | it('undo list if cursor at middle of line', function () { 639 | setVisualValue('One\n\n- T|wo\n\nThree\n') 640 | clickToolbar('md-unordered-list') 641 | assert.equal('One\n\nT|wo\n\nThree\n', visualValue()) 642 | }) 643 | 644 | it('undo list if partial line is selected', function () { 645 | setVisualValue('One\n\n- T|w|o\n\nThree\n') 646 | clickToolbar('md-unordered-list') 647 | assert.equal('One\n\n|Two|\n\nThree\n', visualValue()) 648 | }) 649 | 650 | it('undo two lines list if two lines are selected', function () { 651 | setVisualValue('|- One\n- Two|\n\nThree\n') 652 | clickToolbar('md-unordered-list') 653 | assert.equal('|One\nTwo|\n\nThree\n', visualValue()) 654 | }) 655 | 656 | it('undo two lines list if 2 lines are partially selected', function () { 657 | setVisualValue('- O|ne\n- Tw|o\n\nThree\n') 658 | clickToolbar('md-unordered-list') 659 | assert.equal('|One\nTwo|\n\nThree\n', visualValue()) 660 | }) 661 | }) 662 | 663 | describe('lists', function () { 664 | it('does not stack list styles when selecting multiple lines', function () { 665 | setVisualValue('One\n|Two\nThree|\n') 666 | clickToolbar('md-ordered-list') 667 | clickToolbar('md-unordered-list') 668 | assert.equal('One\n\n|- Two\n- Three|\n', visualValue()) 669 | }) 670 | 671 | it('does not stack list styles when selecting one line', function () { 672 | setVisualValue('One\n|Two|\nThree\n') 673 | clickToolbar('md-ordered-list') 674 | clickToolbar('md-unordered-list') 675 | assert.equal('One\n\n|- Two|\n\nThree\n', visualValue()) 676 | }) 677 | 678 | it('turns line into list when you click the unordered list icon with selection', function () { 679 | setVisualValue('One\n|Two|\nThree\n') 680 | clickToolbar('md-unordered-list') 681 | assert.equal('One\n\n|- Two|\n\nThree\n', visualValue()) 682 | }) 683 | 684 | it('turns line into list when you click the unordered list icon without selection', function () { 685 | setVisualValue('One\n|Two and two\nThree\n') 686 | clickToolbar('md-unordered-list') 687 | assert.equal('One\n\n- |Two and two\n\nThree\n', visualValue()) 688 | }) 689 | 690 | it('turns multiple lines into list when you click the unordered list icon', function () { 691 | setVisualValue('One\n|Two\nThree|\n') 692 | clickToolbar('md-unordered-list') 693 | assert.equal('One\n\n|- Two\n- Three|\n', visualValue()) 694 | }) 695 | 696 | it('prefixes newlines when a list is created on the last line', function () { 697 | setVisualValue("Here's a |list:|") 698 | clickToolbar('md-unordered-list') 699 | assert.equal("|- Here's a list:|", visualValue()) 700 | }) 701 | 702 | it('surrounds list with newlines when a list is created on an existing line', function () { 703 | setVisualValue("Here's a list:|One|\nThis is text after the list") 704 | clickToolbar('md-unordered-list') 705 | assert.equal("|- Here's a list:One|\n\nThis is text after the list", visualValue()) 706 | }) 707 | 708 | it('undo the list when button is clicked again', function () { 709 | setVisualValue('|Two|') 710 | clickToolbar('md-unordered-list') 711 | assert.equal('|- Two|', visualValue()) 712 | clickToolbar('md-unordered-list') 713 | assert.equal('|Two|', visualValue()) 714 | }) 715 | 716 | it('creates ordered list without selection', function () { 717 | setVisualValue('apple\n|pear\nbanana\n') 718 | clickToolbar('md-ordered-list') 719 | assert.equal('apple\n\n1. |pear\n\nbanana\n', visualValue()) 720 | }) 721 | 722 | it('undo an ordered list without selection', function () { 723 | setVisualValue('apple\n1. |pear\nbanana\n') 724 | clickToolbar('md-ordered-list') 725 | assert.equal('apple\n|pear\nbanana\n', visualValue()) 726 | }) 727 | 728 | it('undo an ordered list without selection and puts cursor at the right position', function () { 729 | setVisualValue('apple\n1. pea|r\nbanana\n') 730 | clickToolbar('md-ordered-list') 731 | assert.equal('apple\npea|r\nbanana\n', visualValue()) 732 | }) 733 | 734 | it('creates ordered list by selecting one line', function () { 735 | setVisualValue('apple\n|pear|\nbanana\n') 736 | clickToolbar('md-ordered-list') 737 | assert.equal('apple\n\n|1. pear|\n\nbanana\n', visualValue()) 738 | }) 739 | 740 | it('undo an ordered list by selecting one line', function () { 741 | setVisualValue('apple\n|1. pear|\nbanana\n') 742 | clickToolbar('md-ordered-list') 743 | assert.equal('apple\n|pear|\nbanana\n', visualValue()) 744 | }) 745 | 746 | it('creates ordered list with incrementing values by selecting multiple lines', function () { 747 | setVisualValue('|One\nTwo\nThree|\n') 748 | clickToolbar('md-ordered-list') 749 | assert.equal('|1. One\n2. Two\n3. Three|\n', visualValue()) 750 | }) 751 | 752 | it('undo an ordered list by selecting multiple styled lines', function () { 753 | setVisualValue('|1. One\n2. Two\n3. Three|\n') 754 | clickToolbar('md-ordered-list') 755 | assert.equal('|One\nTwo\nThree|\n', visualValue()) 756 | }) 757 | }) 758 | 759 | describe('code', function () { 760 | it('surrounds a line with backticks if you click the code icon', function () { 761 | setVisualValue("|puts 'Hello, world!'|") 762 | clickToolbar('md-code') 763 | assert.equal("`|puts 'Hello, world!'|`", visualValue()) 764 | }) 765 | 766 | it('surrounds multiple lines with triple backticks if you click the code icon', function () { 767 | setVisualValue('|class Greeter\n def hello_world\n "Hello World!"\n end\nend|') 768 | clickToolbar('md-code') 769 | assert.equal('```\n|class Greeter\n def hello_world\n "Hello World!"\n end\nend|\n```', visualValue()) 770 | }) 771 | 772 | it('removes backticks from a line if you click the code icon again', function () { 773 | setVisualValue("`|puts 'Hello, world!'|`") 774 | clickToolbar('md-code') 775 | assert.equal("|puts 'Hello, world!'|", visualValue()) 776 | }) 777 | 778 | it('removes triple backticks on multiple lines if you click the code icon', function () { 779 | setVisualValue('```\n|class Greeter\n def hello_world\n "Hello World!"\n end\nend|\n```') 780 | clickToolbar('md-code') 781 | assert.equal('|class Greeter\n def hello_world\n "Hello World!"\n end\nend|', visualValue()) 782 | }) 783 | }) 784 | 785 | describe('links', function () { 786 | it('inserts link syntax with cursor in description', function () { 787 | setVisualValue('|') 788 | clickToolbar('md-link') 789 | assert.equal('[|](url)', visualValue()) 790 | }) 791 | 792 | it('selected url is wrapped in link syntax with cursor in description', function () { 793 | setVisualValue("GitHub's homepage is |https://github.com/|") 794 | clickToolbar('md-link') 795 | assert.equal("GitHub's homepage is [|](https://github.com/)", visualValue()) 796 | }) 797 | 798 | it('cursor on url is wrapped in link syntax with cursor in description', function () { 799 | setVisualValue("GitHub's homepage is https://git|hub.com/") 800 | clickToolbar('md-link') 801 | assert.equal("GitHub's homepage is [|](https://github.com/)", visualValue()) 802 | }) 803 | 804 | it('selected plan text is wrapped in link syntax with cursor in url', function () { 805 | setVisualValue("GitHub's |homepage|") 806 | clickToolbar('md-link') 807 | assert.equal("GitHub's [homepage](|url|)", visualValue()) 808 | }) 809 | }) 810 | 811 | describe('images', function () { 812 | it('inserts image syntax with cursor in description', function () { 813 | setVisualValue('|') 814 | clickToolbar('md-image') 815 | assert.equal('![|](url)', visualValue()) 816 | }) 817 | 818 | it('selected url is wrapped in image syntax with cursor in description', function () { 819 | setVisualValue('Octocat is |https://octodex.github.com/images/original.png|') 820 | clickToolbar('md-image') 821 | assert.equal('Octocat is ![|](https://octodex.github.com/images/original.png)', visualValue()) 822 | }) 823 | 824 | it('cursor on url is wrapped in image syntax with cursor in description', function () { 825 | setVisualValue('Octocat is https://octodex.git|hub.com/images/original.png') 826 | clickToolbar('md-image') 827 | assert.equal('Octocat is ![|](https://octodex.github.com/images/original.png)', visualValue()) 828 | }) 829 | 830 | it('selected plan text is wrapped in image syntax with cursor in url', function () { 831 | setVisualValue("GitHub's |logo|") 832 | clickToolbar('md-image') 833 | assert.equal("GitHub's ![logo](|url|)", visualValue()) 834 | }) 835 | }) 836 | 837 | describe('header', function () { 838 | it('sets the level correctly even when dynamically created', function () { 839 | const headerElement = document.createElement('md-header') 840 | headerElement.setAttribute('level', '2') 841 | headerElement.textContent = 'h2' 842 | const toolbar = document.querySelector('markdown-toolbar') 843 | toolbar.appendChild(headerElement) 844 | 845 | setVisualValue('|title|') 846 | clickToolbar('md-header[level="2"]') 847 | assert.equal('## |title|', visualValue()) 848 | }) 849 | 850 | it('inserts header syntax with cursor in description', function () { 851 | setVisualValue('|title|') 852 | clickToolbar('md-header') 853 | assert.equal('### |title|', visualValue()) 854 | }) 855 | 856 | it('inserts header 1 syntax with cursor in description', function () { 857 | setVisualValue('|title|') 858 | clickToolbar('md-header[level="1"]') 859 | assert.equal('# |title|', visualValue()) 860 | }) 861 | 862 | it('does not insert header for invalid level', function () { 863 | setVisualValue('|title|') 864 | clickToolbar('md-header[level="10"]') 865 | assert.equal('|title|', visualValue()) 866 | }) 867 | 868 | it('dynamically changes header level based on the level attribute', function () { 869 | setVisualValue('|title|') 870 | const headerButton = document.querySelector('md-header[level="1"]') 871 | headerButton.setAttribute('level', '2') 872 | headerButton.click() 873 | 874 | assert.equal('## |title|', visualValue()) 875 | }) 876 | 877 | it('can be triggered from a data-md-button', function () { 878 | const headerElement = document.createElement('button') 879 | headerElement.setAttribute('data-md-button', 'header-6') 880 | const toolbar = document.querySelector('markdown-toolbar') 881 | toolbar.appendChild(headerElement) 882 | setVisualValue('|title|') 883 | headerElement.click() 884 | 885 | assert.equal('###### |title|', visualValue()) 886 | }) 887 | }) 888 | }) 889 | }) 890 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "target": "es2017", 5 | "strict": true, 6 | "declaration": true, 7 | "outDir": "dist", 8 | "removeComments": true, 9 | "preserveConstEnums": true 10 | }, 11 | "files": [ 12 | "src/index.ts" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------