From fe0dfdf6c46d3323ac7358132199f5a1208f5ea7 Mon Sep 17 00:00:00 2001 From: Neil Jenkins Date: Mon, 23 Jan 2023 11:35:12 +1100 Subject: [PATCH] Squire 2.0 This is a massive refactor to port Squire to TypeScript, fix a bunch of small bugs and modernise our tooling. The development was done on an internal repository, so apologies to anyone following externally for the commit dump; updates from here should come as real commits again. Co-authored-by: Joe Woods --- .eslintrc.cjs | 67 + .gitignore | 1 - .gitlab-ci.yml | 5 + .jscsrc | 6 - .jshintrc | 19 - .npmignore | 15 +- CHANGELOG.md | 56 + Demo.html | 30 +- LICENSE | 2 +- Makefile | 22 - README.md | 185 +- babel.config.cjs | 13 + bower.json | 24 - build.js | 38 + build/document.html | 54 - build/squire-raw.js | 5015 ---------- build/squire.js | 1 - dist/squire-raw.js | 4066 ++++++++ dist/squire-raw.mjs | 4089 ++++++++ dist/squire.js | 12 + dist/squire.js.map | 7 + dist/squire.mjs | 12 + dist/squire.mjs.map | 7 + package-lock.json | 15474 +++++++++++++++++++++++++++++++ package.json | 74 +- prettier.config.cjs | 16 + source/Clean.js | 345 - source/Clean.ts | 391 + source/Clipboard.js | 351 - source/Clipboard.ts | 422 + source/Constants.js | 43 - source/Constants.ts | 52 + source/Editor.js | 2379 ----- source/Editor.ts | 2816 ++++++ source/KeyHandlers.js | 625 -- source/Legacy.ts | 3 + source/Node.js | 518 -- source/Range.js | 572 -- source/Squire.ts | 3 + source/TreeWalker.js | 122 - source/document.html | 54 - source/exports.js | 43 - source/intro.js | 6 - source/keyboard/Backspace.ts | 92 + source/keyboard/Delete.ts | 100 + source/keyboard/Enter.ts | 12 + source/keyboard/KeyHandlers.ts | 227 + source/keyboard/KeyHelpers.ts | 140 + source/keyboard/Space.ts | 57 + source/keyboard/Tab.ts | 48 + source/node/Block.ts | 37 + source/node/Category.ts | 79 + source/node/MergeSplit.ts | 308 + source/node/Node.ts | 163 + source/node/TreeIterator.ts | 116 + source/node/Whitespace.ts | 69 + source/outro.js | 22 - source/range/Block.ts | 170 + source/range/Boundaries.ts | 173 + source/range/InsertDelete.ts | 442 + test/blank.html | 0 test/index.html | 23 - test/squire.spec.js | 430 - test/squire.spec.ts | 530 ++ tsconfig.json | 21 + 65 files changed, 30477 insertions(+), 10837 deletions(-) create mode 100644 .eslintrc.cjs create mode 100644 .gitlab-ci.yml delete mode 100644 .jscsrc delete mode 100644 .jshintrc create mode 100644 CHANGELOG.md delete mode 100644 Makefile create mode 100644 babel.config.cjs delete mode 100644 bower.json create mode 100755 build.js delete mode 100644 build/document.html delete mode 100644 build/squire-raw.js delete mode 100644 build/squire.js create mode 100644 dist/squire-raw.js create mode 100644 dist/squire-raw.mjs create mode 100644 dist/squire.js create mode 100644 dist/squire.js.map create mode 100644 dist/squire.mjs create mode 100644 dist/squire.mjs.map create mode 100644 package-lock.json create mode 100644 prettier.config.cjs delete mode 100644 source/Clean.js create mode 100644 source/Clean.ts delete mode 100644 source/Clipboard.js create mode 100644 source/Clipboard.ts delete mode 100644 source/Constants.js create mode 100644 source/Constants.ts delete mode 100644 source/Editor.js create mode 100644 source/Editor.ts delete mode 100644 source/KeyHandlers.js create mode 100644 source/Legacy.ts delete mode 100644 source/Node.js delete mode 100644 source/Range.js create mode 100644 source/Squire.ts delete mode 100644 source/TreeWalker.js delete mode 100644 source/document.html delete mode 100644 source/exports.js delete mode 100644 source/intro.js create mode 100644 source/keyboard/Backspace.ts create mode 100644 source/keyboard/Delete.ts create mode 100644 source/keyboard/Enter.ts create mode 100644 source/keyboard/KeyHandlers.ts create mode 100644 source/keyboard/KeyHelpers.ts create mode 100644 source/keyboard/Space.ts create mode 100644 source/keyboard/Tab.ts create mode 100644 source/node/Block.ts create mode 100644 source/node/Category.ts create mode 100644 source/node/MergeSplit.ts create mode 100644 source/node/Node.ts create mode 100644 source/node/TreeIterator.ts create mode 100644 source/node/Whitespace.ts delete mode 100644 source/outro.js create mode 100644 source/range/Block.ts create mode 100644 source/range/Boundaries.ts create mode 100644 source/range/InsertDelete.ts delete mode 100644 test/blank.html delete mode 100644 test/index.html delete mode 100644 test/squire.spec.js create mode 100644 test/squire.spec.ts create mode 100644 tsconfig.json diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..845fe7a --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,67 @@ +/*global module */ +module.exports = { + ignorePatterns: ['/dist/**', '/node_modules/**'], + env: { + es6: true, + }, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + rules: { + // To tighten up in the future + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-explicit-any': 'off', + // Possible errors + 'no-constant-condition': ['error', { checkLoops: false }], + 'no-control-regex': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-prototype-builtins': 'off', + 'no-shadow': 'error', + 'no-useless-backreference': 'error', + // Best practices + 'array-callback-return': 'error', + 'consistent-return': 'error', + 'curly': 'error', + 'default-case-last': 'error', + 'dot-notation': 'error', + 'eqeqeq': ['error', 'always', { null: 'ignore' }], + 'no-caller': 'error', + 'no-eval': 'error', + 'no-extra-bind': 'error', + 'no-floating-decimal': 'error', + 'no-implied-eval': 'error', + 'no-implicit-globals': 'error', + 'no-lone-blocks': 'error', + 'no-loop-func': 'error', // Not sure about this one; see if it hits + 'no-new-func': 'error', + 'no-new-wrappers': 'error', + 'no-octal-escape': 'error', + // 'no-plusplus': 'error', + 'no-return-assign': 'error', + 'no-script-url': 'error', + 'no-self-compare': 'error', + 'no-sequences': 'error', + 'no-throw-literal': 'error', + 'no-unmodified-loop-condition': 'error', + 'no-unused-expressions': 'error', + 'no-useless-call': 'error', + 'no-useless-concat': 'error', + 'no-useless-escape': 'off', + 'no-useless-return': 'error', + 'prefer-regex-literals': 'error', + 'radix': 'error', + // Variables + 'no-label-var': 'error', + // Stylistic issues + 'one-var': ['error', 'never'], + 'one-var-declaration-per-line': 'error', + // ECMAScript 6 + 'no-var': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-const': 'error', + }, +}; diff --git a/.gitignore b/.gitignore index a088b6f..3c3629e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ node_modules -bower_components diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..bcc31e1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,5 @@ +test: + stage: test + script: npm ci --ignore-scripts --force && npm run test + only: + - merge_requests diff --git a/.jscsrc b/.jscsrc deleted file mode 100644 index 891243e..0000000 --- a/.jscsrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "disallowSpacesInsideParentheses": false, - "disallowSpacesInFunctionDeclaration": { - "beforeOpeningRoundBrace": false - } -} diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index eb496a6..0000000 --- a/.jshintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "curly": true, - "eqeqeq": true, - "es3": true, - "immed": true, - "latedef": true, - "newcap": true, - "noarg": true, - "quotmark": false, - "unused": true, - "undef": true, - - "boss": true, - "eqnull": true, - "strict": true, - - "browser": true, - "devel": true -} diff --git a/.npmignore b/.npmignore index 24d2b08..693dee5 100644 --- a/.npmignore +++ b/.npmignore @@ -1,9 +1,12 @@ -Makefile -.jscsrc -.jshintrc -.gitignore -.editorconfig -bower.json source/ test/ +Makefile +.editorconfig +.eslintrc.cjs +.gitignore +.gitlab-ci.yml +babel.config.cjs Demo.html +prettier.config.cjs +rollup.config.js +tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79df0ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to this project will be documented in this file, starting from v2.0.0. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2023-01-23 + +### Added + +- Builds as an ES module. + +### Changed + +- All code ported to Typescript and ES modules for compatibility with modern + frontend projects and future maintainability. +- New off-the-shelf tooling for the build process and code quality assurance. +- Config option `sanitizeToDOMFragment` no longer takes an `isPaste` + argument. +- Custom events (e.g. `pathChange`) use the browser native CustomEvent class, + which means the custom properties (e.g. `path`) are now available on the + `detail` property of the event object, rather than directly added to the + event object. +- When the user pastes an image, instead of simulating drag/drop events, + Squire now fires a custom `pasteImage` event, with a `clipboardData` + property on the `detail` +- When there is a selection and you paste text that looks like a URL, it will + now make the selection a link rather than replacing it with the URL text. +- In the object returned by the `getFontInfo` method, the font size property + is now called "fontSize" instead of "size", and the font family property is + now called "fontFamily" instead of "family". This means all properties now + use the same name as in the CSSStyleDeclaration API. +- The `key` function for setKeyHandler now uses the same names + (including case) as the KeyboardEvent.key property + (https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key). + For example, `"enter"` is now `"Enter"` and `"left"` is now `"ArrowLeft"`. + +### Fixed + +- Fixed iOS autocorrect/text substitution fails to activate when hitting + "enter". +- Fixed Samsung keyboard on Android causes bizarre changes to the input, + making it unusable. +- Fixed bug trimming insignificant trailing white space, which could result + in some formatting actions behaving oddly. +- Fixed spaces "vanish" sometimes after deleting text. + +### Removed + +- Support for any version of IE. +- Support for using an iframe document as the editor, rather than just a + normal DOM node. +- Support for using it without an HTML sanitiser - this is essential for + security, so it's now required. +- `isInsertedHTMLSanitized` and `isSetHTMLSanitized` config options - as per + the above, the HTML is always sanitised on insertion for security. diff --git a/Demo.html b/Demo.html index 26e386e..92e6077 100644 --- a/Demo.html +++ b/Demo.html @@ -3,7 +3,7 @@ - HTML Editor Test + Squire Editor Demo -

HTML Editor Test

+

Squire Editor Demo

-

This is a really simple demo, with the most trivial of UI integrations

+

Squire is a rich text editor primarily built for email apps. It’s designed to be integrated with your own UI framework, and so does not provide its own UI toolbar, widgets or overlays. This is a really simple demo, with the most trivial of UI integrations, to show the raw component in action. Learn more and see the source on GitHub.

Bold Unbold @@ -90,12 +90,11 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad Font face

- Text colour - Text highlight + Text color + Text highlight Link

- Make Header Quote Dequote        @@ -114,12 +113,13 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad Redo

-
- + + +``` + +2. Get a reference to the DOM node in the document that you want to make into the rich textarea, e.g. `node = document.getElementById('editor-div')`. +3. Call `editor = new Squire(node)`. This will instantiate a new Squire instance. Please note, this will remove any current children of the node; you must use the `setHTML` command after initialising to set any content. + +## Editor lifecycle + +You can have multiple Squire instances in a single page without issue. If you are using the editor as part of a long lived single-page app, be sure to call `editor.destroy()` once you have finished using an instance to ensure it doesn't leak resources. ### Security -Malicious HTML can be a source of XSS and other security issues. I highly recommended you use [DOMPurify](https://github.com/cure53/DOMPurify) with Squire to prevent these security issues. If DOMPurify is included in the page (with the standard global variable), Squire will automatically sanitise any HTML passed in via `setHTML` or `insertHTML` (which includes HTML the user pastes from the clipboard). +Malicious HTML can be a source of XSS and other security issues. You MUST provide a method to safely convert raw HTML into DOM nodes to use Squire. Squire will automatically integrate with [DOMPurify](https://github.com/cure53/DOMPurify) to do this if present in the page. Otherwise you must set a custom `sanitizeToDOMFragment` function in your config. -You can override this by setting properties on the config object (the second argument passed to the constructor, see below). The properties are: +- **sanitizeToDOMFragment**: `(html: string, editor: Squire) => DocumentFragment` + A custom sanitization function. This will be called instead of the default call to DOMPurify to sanitize the potentially dangerous HTML. It is passed two arguments: the first is the string of HTML, the second is the Squire instance. It must return a DOM Fragment node belonging to the same document as the editor's root node, with the contents being clean DOM nodes to set/insert. -* **isSetHTMLSanitized**: `Boolean` - Should the HTML passed via calls to `setHTML` be passed to the sanitizer? If your app always sanitizes the HTML in some other way before calling this, you may wish to set this to `false` to avoid the overhead. -* **isInsertedHTMLSanitized**: `Boolean` (defaults to `true`) – Should the HTML passed via calls to `insertHTML` be passed to the sanitizer? This includes when the user pastes from the clipboard. Since you cannot control what other apps put on the clipboard, it is highly recommended you do not set this to `false`. -* **sanitizeToDOMFragment**: `(html: String, isPaste: Boolean, self: Squire) -> DOMFragment` - A custom sanitization function. This will be called instead of the default call to DOMPurify to sanitize the potentially dangerous HTML. It is passed three arguments: the first is the string of HTML, the second is a boolean indicating if this content has come from the clipboard, rather than an explicit call by your own code, the third is the squire instance. It must return a DOM Fragment node belonging to the same document as the editor's root node, with the contents being clean DOM nodes to set/insert. - -Advanced usage --------------- +## Advanced usage Squire provides an engine that handles the heavy work for you, making it easy to add extra features. With the `changeFormat` method you can easily add or remove any inline formatting you wish. And the `modifyBlocks` method can be used to make complicated block-level changes in a relatively easy manner. -If you load the library into a top-level document (rather than an iframe), or load it in an iframe without the `data-squireinit="true"` attribute on its `` element, it will not turn the page into an editable document, but will instead add a constructor named `Squire` to the global scope. - -You can also require the NPM package [squire-rte](https://www.npmjs.com/package/squire-rte) to import `Squire` in a modular program without adding names to the global namespace. - -Call `new Squire( document )`, with the `document` from an iframe to instantiate multiple rich text areas on the same page efficiently. Note, for compatibility with all browsers (particularly Firefox), you MUST wait for the iframe's `onload` event to fire before instantiating Squire. - If you need more commands than in the simple API, I suggest you check out the source code (it's not very long), and see how a lot of the other API methods are implemented in terms of these two methods. The general philosophy of Squire is to allow the browser to do as much as it can (which unfortunately is not very much), but take control anywhere it deviates from what is required, or there are significant cross-browser differences. As such, the [`document.execCommand`](https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand) method is not used at all; instead all formatting is done via custom functions, and certain keys, such as 'enter' and 'backspace' are handled by the editor. ### Setting the default block style -By default, the editor will use a `
` for blank lines, as most users have been conditioned by Microsoft Word to expect Enter to act like pressing return on a typewriter. If you would like to use `

` tags (or anything else) for the default block type instead, you can pass a config object as the second parameter to the squire constructor. You can also +By default, the editor will use a `

` for blank lines, as most users have been conditioned by Microsoft Word to expect Enter to act like pressing return on a typewriter. If you would like to use `

` tags (or anything else) for the default block type instead, you can pass a config object as the second parameter to the Squire constructor. You can also pass a set of attributes to apply to each default block: - var editor = new Squire( document, { + var editor = new Squire(document, { blockTag: 'P', blockAttributes: { style: 'font-size: 16px;' } - }) - -If using the simple setup, call `editor.setConfig(…);` with your -config object instead. Be sure to do this *before* calling `editor.setHTML()`. + }); ### Determining button state @@ -83,35 +66,33 @@ If you are adding a UI to Squire, you'll probably want to show a button in diffe The efficient way to determine the state for most buttons is to monitor the "pathChange" event in the editor, and determine the state from the new path. If the selection goes across nodes, you will need to call the `hasFormat` method for each of your buttons to determine whether the styles are active. See the `getPath` and `hasFormat` documentation for more information. -License -------- +## License Squire is released under the MIT license. See LICENSE for full license. -API ---- +## API ### addEventListener Attach an event listener to the editor. The handler can be either a function or an object with a `handleEvent` method. This function or method will be called whenever the event fires, with an event object as the sole argument. The following events may be observed: -* **focus**: The editor gained focus. -* **blur**: The editor lost focus -* **keydown**: Standard [DOM keydown event](https://developer.mozilla.org/en-US/docs/Web/Events/keydown). -* **keypress**: Standard [DOM keypress event](https://developer.mozilla.org/en-US/docs/Web/Events/keypress). -* **keyup**: Standard [DOM keyup event](https://developer.mozilla.org/en-US/docs/Web/Events/keyup). -* **input**: The user inserted, deleted or changed the style of some text; in other words, the result for `editor.getHTML()` will have changed. -* **pathChange**: The path (see getPath documentation) to the cursor has changed. The new path is available as the `path` property on the event object. -* **select**: The user selected some text. -* **cursor**: The user cleared their selection or moved the cursor to a - different position. -* **undoStateChange**: The availability of undo and/or redo has changed. The event object has two boolean properties, `canUndo` and `canRedo` to let you know the new state. -* **willPaste**: The user is pasting content into the document. The content that will be inserted is available as either the `fragment` property on the event object, or the `text` property for plain text being inserted into a `

`. You can modify this text/fragment in your event handler to change what will be pasted. You can also call the `preventDefault` on the event object to cancel the paste operation.
+-   **focus**: The editor gained focus.
+-   **blur**: The editor lost focus
+-   **keydown**: Standard [DOM keydown event](https://developer.mozilla.org/en-US/docs/Web/Events/keydown).
+-   **keypress**: Standard [DOM keypress event](https://developer.mozilla.org/en-US/docs/Web/Events/keypress).
+-   **keyup**: Standard [DOM keyup event](https://developer.mozilla.org/en-US/docs/Web/Events/keyup).
+-   **input**: The user inserted, deleted or changed the style of some text; in other words, the result for `editor.getHTML()` will have changed.
+-   **pathChange**: The path (see getPath documentation) to the cursor has changed. The new path is available as the `path` property on the event's `detail` property object.
+-   **select**: The user selected some text.
+-   **cursor**: The user cleared their selection or moved the cursor to a
+    different position.
+-   **undoStateChange**: The availability of undo and/or redo has changed. The event object has a `detail` property, which is an object with two boolean properties, `canUndo` and `canRedo` to let you know the new state.
+-   **willPaste**: The user is pasting content into the document. The content that will be inserted is available as either the `fragment` property, or the `text` property for plain text, on the `detail` property of the event. You can modify this text/fragment in your event handler to change what will be pasted. You can also call the `preventDefault` on the event object to cancel the paste operation.
 
 The method takes two arguments:
 
-* **type**: The event to listen for. e.g. 'focus'.
-* **handler**: The callback function to invoke
+-   **type**: The event to listen for. e.g. 'focus'.
+-   **handler**: The callback function to invoke
 
 Returns self (the Squire instance).
 
@@ -121,8 +102,8 @@ Remove an event listener attached via the addEventListener method.
 
 The method takes two arguments:
 
-* **type**: The event type the handler was registered for.
-* **handler**: The handler to remove.
+-   **type**: The event type the handler was registered for.
+-   **handler**: The handler to remove.
 
 Returns self (the Squire instance).
 
@@ -132,11 +113,11 @@ Adds or removes a keyboard shortcut. You can use this to override the default ke
 
 This method takes two arguments:
 
-* **key**: The key to handle, including any modifiers in alphabetical order. e.g. `"alt-ctrl-meta-shift-enter"`
-* **fn**: The function to be called when this key is pressed, or `null` if removing a key handler. The function will be passed three arguments when called:
-  * **self**: A reference to the Squire instance.
-  * **event**: The key event object.
-  * **range**: A Range object representing the current selection.
+-   **key**: The key to handle, including any modifiers in alphabetical order. e.g. `"Alt-Ctrl-Meta-Shift-Enter"`
+-   **fn**: The function to be called when this key is pressed, or `null` if removing a key handler. The function will be passed three arguments when called:
+    -   **self**: A reference to the Squire instance.
+    -   **event**: The key event object.
+    -   **range**: A Range object representing the current selection.
 
 Returns self (the Squire instance).
 
@@ -156,10 +137,6 @@ The method takes no arguments.
 
 Returns self (the Squire instance).
 
-### getDocument
-
-Returns the `document` object of the editable area. May be useful to do transformations outside the realm of the API.
-
 ### getHTML
 
 Returns the HTML value of the editor in its current state. This value is equivalent to the contents of the `` tag and does not include any surrounding boilerplate.
@@ -170,7 +147,7 @@ Sets the HTML value for the editor. The value supplied should not contain `` node. e.g. `{ class: 'class-name' }`. Any `src` attribute will be overwritten by the url given as the first argument.
+-   **src**: The source path for the image.
+-   **attributes**: (optional) An object containing other attributes to set on the `` node. e.g. `{ class: 'class-name' }`. Any `src` attribute will be overwritten by the url given as the first argument.
 
 Returns a reference to the newly inserted image element.
 
@@ -195,7 +172,7 @@ Inserts an HTML fragment at the current cursor location, or replaces the selecti
 
 The method takes one argument:
 
-* **html**: The html to insert.
+-   **html**: The html to insert.
 
 Returns self (the Squire instance).
 
@@ -205,16 +182,16 @@ Returns the path through the DOM tree from the `` element to the current c
 
 ### getFontInfo
 
-Returns an object containing the active font family, size, colour and background colour for the the current cursor position, if any are set. The property names are respectively `family`, `size`, `color` and `backgroundColor`. It looks at style attributes to detect this, so will not detect `` tags or non-inline styles. If a selection across multiple elements has been made, it will return an empty object.
+Returns an object containing the active font family, size, color and background color for the the current cursor position, if any are set. The property names are respectively `fontFamily`, `fontSize`, `color` and `backgroundColor` (matching the CSS property names). It looks at style attributes to detect this, so will not detect `` tags or non-inline styles. If a selection across multiple elements has been made, it will return an empty object.
 
 ### createRange
 
 Creates a range in the document belonging to the editor. Takes 4 arguments, matching the [W3C Range properties](https://developer.mozilla.org/en-US/docs/Web/API/Range) they set:
 
-* **startContainer**
-* **startOffset**
-* **endContainer** (optional; if not collapsed)
-* **endOffset** (optional; if not collapsed)
+-   **startContainer**
+-   **startOffset**
+-   **endContainer** (optional; if not collapsed)
+-   **endOffset** (optional; if not collapsed)
 
 ### getCursorPosition
 
@@ -231,7 +208,7 @@ Changes the current selection/cursor position.
 
 The method takes one argument:
 
-* **range**: The [W3C Range object](https://developer.mozilla.org/en-US/docs/Web/API/Range) representing the desired selection.
+-   **range**: The [W3C Range object](https://developer.mozilla.org/en-US/docs/Web/API/Range) representing the desired selection.
 
 Returns self (the Squire instance).
 
@@ -252,7 +229,7 @@ Returns self (the Squire instance).
 ### saveUndoState
 
 Saves an undo checkpoint with the current editor state. Methods that modify the
-state (e.g. bold/setHighlightColour/modifyBlocks) will automatically save undo
+state (e.g. bold/setHighlightColor/modifyBlocks) will automatically save undo
 checkpoints; you only need this method if you want to modify the DOM outside of
 one of these methods, and you want to save an undo checkpoint first.
 
@@ -276,8 +253,8 @@ Queries the editor for whether a particular format is applied anywhere in the cu
 
 The method takes two arguments:
 
-* **tag**: The tag of the format
-* **attributes**: (optional) Any attributes the format.
+-   **tag**: The tag of the format
+-   **attributes**: (optional) Any attributes the format.
 
 Returns `true` if the entire selection is contained within an element with the specified tag and attributes, otherwise returns `false`.
 
@@ -323,8 +300,8 @@ Makes the currently selected text a link. If no text is selected, the URL or ema
 
 This method takes two arguments:
 
-* **url**: The url or email to link to.
-* **attributes**: (optional) An object containing other attributes to set on the `` node. e.g. `{ target: '_blank' }`. Any `href` attribute will be overwritten by the url given as the first argument.
+-   **url**: The url or email to link to.
+-   **attributes**: (optional) An object containing other attributes to set on the `` node. e.g. `{ target: '_blank' }`. Any `href` attribute will be overwritten by the url given as the first argument.
 
 Returns self (the Squire instance).
 
@@ -340,7 +317,7 @@ Sets the font face for the selected text.
 
 This method takes one argument:
 
-* **font**: A comma-separated list of fonts (in order of preference) to set.
+-   **font**: A comma-separated list of fonts (in order of preference) to set.
 
 Returns self (the Squire instance).
 
@@ -350,27 +327,27 @@ Sets the font size for the selected text.
 
 This method takes one argument:
 
-* **size**: A size to set. Any CSS [length value](https://developer.mozilla.org/en-US/docs/Web/CSS/length) or [absolute-size value](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_values_syntax#syntax-absolute-size) is accepted, e.g. '13px', or 'small'.
+-   **size**: A size to set. Any CSS [length value](https://developer.mozilla.org/en-US/docs/Web/CSS/length) or [absolute-size value](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_values_syntax#syntax-absolute-size) is accepted, e.g. '13px', or 'small'.
 
 Returns self (the Squire instance).
 
-### setTextColour
+### setTextColor
 
-Sets the colour of the selected text.
+Sets the color of the selected text.
 
 This method takes one argument:
 
-* **colour**: The colour to set. Any [CSS colour value](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) is accepted, e.g. '#f00', or 'hsl(0,0,0)'.
+-   **color**: The color to set. Any [CSS color value](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) is accepted, e.g. '#f00', or 'hsl(0,0,0)'.
 
 Returns self (the Squire instance).
 
-### setHighlightColour
+### setHighlightColor
 
-Sets the colour of the background of the selected text.
+Sets the color of the background of the selected text.
 
 This method takes one argument:
 
-* **colour**: The colour to set. Any [CSS colour value](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) is accepted, e.g. '#f00', or 'hsl(0,0,0)'.
+-   **color**: The color to set. Any [CSS color value](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) is accepted, e.g. '#f00', or 'hsl(0,0,0)'.
 
 Returns self (the Squire instance).
 
@@ -380,7 +357,7 @@ Sets the text alignment in all blocks at least partially contained by the select
 
 This method takes one argument:
 
-* **alignment**: The direction to align to. Can be 'left', 'right', 'center' or 'justify'.
+-   **alignment**: The direction to align to. Can be 'left', 'right', 'center' or 'justify'.
 
 Returns self (the Squire instance).
 
@@ -390,7 +367,7 @@ Sets the text direction in all blocks at least partially contained by the select
 
 This method takes one argument:
 
-* **direction**: The text direction. Can be 'ltr' or 'rtl'.
+-   **direction**: The text direction. Can be 'ltr' or 'rtl'.
 
 Returns self (the Squire instance).
 
@@ -400,8 +377,8 @@ Executes a function on each block in the current selection, or until the functio
 
 This method takes two arguments:
 
-* **fn** The function to execute on each block node at least partially contained in the current selection. The function will be called with the block node as the only argument.
-* **mutates** A boolean indicating whether your function may modify anything in the document in any way.
+-   **fn** The function to execute on each block node at least partially contained in the current selection. The function will be called with the block node as the only argument.
+-   **mutates** A boolean indicating whether your function may modify anything in the document in any way.
 
 Returns self (the Squire instance).
 
@@ -411,7 +388,7 @@ Extracts a portion of the DOM tree (up to the block boundaries of the current se
 
 This method takes one argument:
 
-* **modify** The function to apply to the extracted DOM tree; gets a document fragment as a sole argument. `this` is bound to the Squire instance. Should return the node or fragment to be reinserted in the DOM.
+-   **modify** The function to apply to the extracted DOM tree; gets a document fragment as a sole argument. `this` is bound to the Squire instance. Should return the node or fragment to be reinserted in the DOM.
 
 Returns self (the Squire instance).
 
@@ -499,5 +476,3 @@ This is useful when the document needs to be changed programmatically, but those
 ### linkRegExp
 
 This is the regular expression used to automatically mark up links when inserting HTML or after pressing space. You can change it if you want to use a custom regular expression for detecting links, or set to `null` to turn off link detection.
-
-
diff --git a/babel.config.cjs b/babel.config.cjs
new file mode 100644
index 0000000..ac058f3
--- /dev/null
+++ b/babel.config.cjs
@@ -0,0 +1,13 @@
+module.exports = {
+    presets: [
+        [
+            '@babel/preset-env',
+            {
+                targets: {
+                    node: 'current',
+                },
+            },
+        ],
+        '@babel/preset-typescript',
+    ],
+};
diff --git a/bower.json b/bower.json
deleted file mode 100644
index 0857880..0000000
--- a/bower.json
+++ /dev/null
@@ -1,24 +0,0 @@
-{
-  "name": "squire-rte",
-  "homepage": "https://github.com/neilj/Squire",
-  "authors": [
-    "Neil Jenkins "
-  ],
-  "description": "Squire is an HTML5 rich text editor, which provides powerful cross-browser normalisation, whilst being supremely lightweight and flexible.",
-  "main": "build/squire.js",
-  "keywords": [
-    "wysiwyg",
-    "editor",
-    "text",
-    "html",
-    "squire"
-  ],
-  "license": "MIT",
-  "ignore": [
-    "**/.*",
-    "node_modules",
-    "bower_components",
-    "test",
-    "tests"
-  ]
-}
diff --git a/build.js b/build.js
new file mode 100755
index 0000000..b487f62
--- /dev/null
+++ b/build.js
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+
+import esbuild from 'esbuild';
+
+Promise.all([
+    esbuild.build({
+        entryPoints: ['source/Legacy.ts'],
+        bundle: true,
+        target: 'es6',
+        format: 'iife',
+        outfile: 'dist/squire-raw.js',
+    }),
+    esbuild.build({
+        entryPoints: ['source/Legacy.ts'],
+        bundle: true,
+        minify: true,
+        sourcemap: 'linked',
+        target: 'es6',
+        format: 'iife',
+        outfile: 'dist/squire.js',
+    }),
+    esbuild.build({
+        entryPoints: ['source/Squire.ts'],
+        bundle: true,
+        target: 'esnext',
+        format: 'esm',
+        outfile: 'dist/squire-raw.mjs',
+    }),
+    esbuild.build({
+        entryPoints: ['source/Squire.ts'],
+        bundle: true,
+        minify: true,
+        sourcemap: 'linked',
+        target: 'esnext',
+        format: 'esm',
+        outfile: 'dist/squire.mjs',
+    }),
+]).catch(() => process.exit(1));
diff --git a/build/document.html b/build/document.html
deleted file mode 100644
index 15fcb3c..0000000
--- a/build/document.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/build/squire-raw.js b/build/squire-raw.js
deleted file mode 100644
index d91d90a..0000000
--- a/build/squire-raw.js
+++ /dev/null
@@ -1,5015 +0,0 @@
-/* Copyright © 2011-2015 by Neil Jenkins. MIT Licensed. */
-
-( function ( doc, undefined ) {
-
-"use strict";
-
-var DOCUMENT_POSITION_PRECEDING = 2; // Node.DOCUMENT_POSITION_PRECEDING
-var ELEMENT_NODE = 1;                // Node.ELEMENT_NODE;
-var TEXT_NODE = 3;                   // Node.TEXT_NODE;
-var DOCUMENT_NODE = 9;               // Node.DOCUMENT_NODE;
-var DOCUMENT_FRAGMENT_NODE = 11;     // Node.DOCUMENT_FRAGMENT_NODE;
-var SHOW_ELEMENT = 1;                // NodeFilter.SHOW_ELEMENT;
-var SHOW_TEXT = 4;                   // NodeFilter.SHOW_TEXT;
-
-var START_TO_START = 0; // Range.START_TO_START
-var START_TO_END = 1;   // Range.START_TO_END
-var END_TO_END = 2;     // Range.END_TO_END
-var END_TO_START = 3;   // Range.END_TO_START
-
-var ZWS = '\u200B';
-
-var win = doc.defaultView;
-
-var ua = navigator.userAgent;
-
-var isAndroid = /Android/.test( ua );
-var isMac = /Mac OS X/.test( ua );
-var isWin = /Windows NT/.test( ua );
-var isIOS = /iP(?:ad|hone|od)/.test( ua ) ||
-    ( isMac && !!navigator.maxTouchPoints );
-
-var isGecko = /Gecko\//.test( ua );
-var isEdge = /Edge\//.test( ua );
-var isWebKit = !isEdge && /WebKit\//.test( ua );
-var isIE = /Trident\/[4567]\./.test( ua );
-
-var ctrlKey = isMac ? 'meta-' : 'ctrl-';
-
-var cantFocusEmptyTextNodes = isWebKit;
-
-var canObserveMutations = typeof MutationObserver !== 'undefined';
-var canWeakMap = typeof WeakMap !== 'undefined';
-
-// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
-var notWS = /[^ \t\r\n]/;
-
-var indexOf = Array.prototype.indexOf;
-
-/*
-    Native TreeWalker is buggy in IE and Opera:
-    * IE9/10 sometimes throw errors when calling TreeWalker#nextNode or
-      TreeWalker#previousNode. No way to feature detect this.
-    * Some versions of Opera have a bug in TreeWalker#previousNode which makes
-      it skip to the wrong node.
-
-    Rather than risk further bugs, it's easiest just to implement our own
-    (subset) of the spec in all browsers.
-*/
-
-var typeToBitArray = {
-    // ELEMENT_NODE
-    1: 1,
-    // ATTRIBUTE_NODE
-    2: 2,
-    // TEXT_NODE
-    3: 4,
-    // COMMENT_NODE
-    8: 128,
-    // DOCUMENT_NODE
-    9: 256,
-    // DOCUMENT_FRAGMENT_NODE
-    11: 1024
-};
-
-var always = function () {
-    return true;
-};
-
-function TreeWalker ( root, nodeType, filter ) {
-    this.root = this.currentNode = root;
-    this.nodeType = nodeType;
-    this.filter = filter || always;
-}
-
-TreeWalker.prototype.nextNode = function () {
-    var current = this.currentNode,
-        root = this.root,
-        nodeType = this.nodeType,
-        filter = this.filter,
-        node;
-    while ( true ) {
-        node = current.firstChild;
-        while ( !node && current ) {
-            if ( current === root ) {
-                break;
-            }
-            node = current.nextSibling;
-            if ( !node ) { current = current.parentNode; }
-        }
-        if ( !node ) {
-            return null;
-        }
-        if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
-                filter( node ) ) {
-            this.currentNode = node;
-            return node;
-        }
-        current = node;
-    }
-};
-
-TreeWalker.prototype.previousNode = function () {
-    var current = this.currentNode,
-        root = this.root,
-        nodeType = this.nodeType,
-        filter = this.filter,
-        node;
-    while ( true ) {
-        if ( current === root ) {
-            return null;
-        }
-        node = current.previousSibling;
-        if ( node ) {
-            while ( current = node.lastChild ) {
-                node = current;
-            }
-        } else {
-            node = current.parentNode;
-        }
-        if ( !node ) {
-            return null;
-        }
-        if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
-                filter( node ) ) {
-            this.currentNode = node;
-            return node;
-        }
-        current = node;
-    }
-};
-
-// Previous node in post-order.
-TreeWalker.prototype.previousPONode = function () {
-    var current = this.currentNode,
-        root = this.root,
-        nodeType = this.nodeType,
-        filter = this.filter,
-        node;
-    while ( true ) {
-        node = current.lastChild;
-        while ( !node && current ) {
-            if ( current === root ) {
-                break;
-            }
-            node = current.previousSibling;
-            if ( !node ) { current = current.parentNode; }
-        }
-        if ( !node ) {
-            return null;
-        }
-        if ( ( typeToBitArray[ node.nodeType ] & nodeType ) &&
-                filter( node ) ) {
-            this.currentNode = node;
-            return node;
-        }
-        current = node;
-    }
-};
-
-var inlineNodeNames  = /^(?:#text|A(?:BBR|CRONYM)?|B(?:R|D[IO])?|C(?:ITE|ODE)|D(?:ATA|EL|FN)|EM|FONT|HR|I(?:FRAME|MG|NPUT|NS)?|KBD|Q|R(?:P|T|UBY)|S(?:AMP|MALL|PAN|TR(?:IKE|ONG)|U[BP])?|TIME|U|VAR|WBR)$/;
-
-var leafNodeNames = {
-    BR: 1,
-    HR: 1,
-    IFRAME: 1,
-    IMG: 1,
-    INPUT: 1
-};
-
-function every ( nodeList, fn ) {
-    var l = nodeList.length;
-    while ( l-- ) {
-        if ( !fn( nodeList[l] ) ) {
-            return false;
-        }
-    }
-    return true;
-}
-
-// ---
-
-var UNKNOWN = 0;
-var INLINE = 1;
-var BLOCK = 2;
-var CONTAINER = 3;
-
-var nodeCategoryCache = canWeakMap ? new WeakMap() : null;
-
-function isLeaf ( node ) {
-    return node.nodeType === ELEMENT_NODE && !!leafNodeNames[ node.nodeName ];
-}
-function getNodeCategory ( node ) {
-    switch ( node.nodeType ) {
-    case TEXT_NODE:
-        return INLINE;
-    case ELEMENT_NODE:
-    case DOCUMENT_FRAGMENT_NODE:
-        if ( canWeakMap && nodeCategoryCache.has( node ) ) {
-            return nodeCategoryCache.get( node );
-        }
-        break;
-    default:
-        return UNKNOWN;
-    }
-
-    var nodeCategory;
-    if ( !every( node.childNodes, isInline ) ) {
-        // Malformed HTML can have block tags inside inline tags. Need to treat
-        // these as containers rather than inline. See #239.
-        nodeCategory = CONTAINER;
-    } else if ( inlineNodeNames.test( node.nodeName ) ) {
-        nodeCategory = INLINE;
-    } else {
-        nodeCategory = BLOCK;
-    }
-    if ( canWeakMap ) {
-        nodeCategoryCache.set( node, nodeCategory );
-    }
-    return nodeCategory;
-}
-function isInline ( node ) {
-    return getNodeCategory( node ) === INLINE;
-}
-function isBlock ( node ) {
-    return getNodeCategory( node ) === BLOCK;
-}
-function isContainer ( node ) {
-    return getNodeCategory( node ) === CONTAINER;
-}
-
-function getBlockWalker ( node, root ) {
-    var walker = new TreeWalker( root, SHOW_ELEMENT, isBlock );
-    walker.currentNode = node;
-    return walker;
-}
-function getPreviousBlock ( node, root ) {
-    node = getBlockWalker( node, root ).previousNode();
-    return node !== root ? node : null;
-}
-function getNextBlock ( node, root ) {
-    node = getBlockWalker( node, root ).nextNode();
-    return node !== root ? node : null;
-}
-
-function isEmptyBlock ( block ) {
-    return !block.textContent && !block.querySelector( 'IMG' );
-}
-
-function areAlike ( node, node2 ) {
-    return !isLeaf( node ) && (
-        node.nodeType === node2.nodeType &&
-        node.nodeName === node2.nodeName &&
-        node.nodeName !== 'A' &&
-        node.className === node2.className &&
-        ( ( !node.style && !node2.style ) ||
-          node.style.cssText === node2.style.cssText )
-    );
-}
-function hasTagAttributes ( node, tag, attributes ) {
-    if ( node.nodeName !== tag ) {
-        return false;
-    }
-    for ( var attr in attributes ) {
-        if ( node.getAttribute( attr ) !== attributes[ attr ] ) {
-            return false;
-        }
-    }
-    return true;
-}
-function getNearest ( node, root, tag, attributes ) {
-    while ( node && node !== root ) {
-        if ( hasTagAttributes( node, tag, attributes ) ) {
-            return node;
-        }
-        node = node.parentNode;
-    }
-    return null;
-}
-function isOrContains ( parent, node ) {
-    while ( node ) {
-        if ( node === parent ) {
-            return true;
-        }
-        node = node.parentNode;
-    }
-    return false;
-}
-
-function getPath ( node, root, config ) {
-    var path = '';
-    var id, className, classNames, dir, styleNames;
-    if ( node && node !== root ) {
-        path = getPath( node.parentNode, root, config );
-        if ( node.nodeType === ELEMENT_NODE ) {
-            path += ( path ? '>' : '' ) + node.nodeName;
-            if ( id = node.id ) {
-                path += '#' + id;
-            }
-            if ( className = node.className.trim() ) {
-                classNames = className.split( /\s\s*/ );
-                classNames.sort();
-                path += '.';
-                path += classNames.join( '.' );
-            }
-            if ( dir = node.dir ) {
-                path += '[dir=' + dir + ']';
-            }
-            if ( classNames ) {
-                styleNames = config.classNames;
-                if ( indexOf.call( classNames, styleNames.highlight ) > -1 ) {
-                    path += '[backgroundColor=' +
-                        node.style.backgroundColor.replace( / /g,'' ) + ']';
-                }
-                if ( indexOf.call( classNames, styleNames.colour ) > -1 ) {
-                    path += '[color=' +
-                        node.style.color.replace( / /g,'' ) + ']';
-                }
-                if ( indexOf.call( classNames, styleNames.fontFamily ) > -1 ) {
-                    path += '[fontFamily=' +
-                        node.style.fontFamily.replace( / /g,'' ) + ']';
-                }
-                if ( indexOf.call( classNames, styleNames.fontSize ) > -1 ) {
-                    path += '[fontSize=' + node.style.fontSize + ']';
-                }
-            }
-        }
-    }
-    return path;
-}
-
-function getLength ( node ) {
-    var nodeType = node.nodeType;
-    return nodeType === ELEMENT_NODE || nodeType === DOCUMENT_FRAGMENT_NODE ?
-        node.childNodes.length : node.length || 0;
-}
-
-function detach ( node ) {
-    var parent = node.parentNode;
-    if ( parent ) {
-        parent.removeChild( node );
-    }
-    return node;
-}
-function replaceWith ( node, node2 ) {
-    var parent = node.parentNode;
-    if ( parent ) {
-        parent.replaceChild( node2, node );
-    }
-}
-function empty ( node ) {
-    var frag = node.ownerDocument.createDocumentFragment(),
-        childNodes = node.childNodes,
-        l = childNodes ? childNodes.length : 0;
-    while ( l-- ) {
-        frag.appendChild( node.firstChild );
-    }
-    return frag;
-}
-
-function createElement ( doc, tag, props, children ) {
-    var el = doc.createElement( tag ),
-        attr, value, i, l;
-    if ( props instanceof Array ) {
-        children = props;
-        props = null;
-    }
-    if ( props ) {
-        for ( attr in props ) {
-            value = props[ attr ];
-            if ( value !== undefined ) {
-                el.setAttribute( attr, value );
-            }
-        }
-    }
-    if ( children ) {
-        for ( i = 0, l = children.length; i < l; i += 1 ) {
-            el.appendChild( children[i] );
-        }
-    }
-    return el;
-}
-
-function fixCursor ( node, root ) {
-    // In Webkit and Gecko, block level elements are collapsed and
-    // unfocusable if they have no content. To remedy this, a 
must be - // inserted. In Opera and IE, we just need a textnode in order for the - // cursor to appear. - var self = root.__squire__; - var doc = node.ownerDocument; - var originalNode = node; - var fixer, child; - - if ( node === root ) { - if ( !( child = node.firstChild ) || child.nodeName === 'BR' ) { - fixer = self.createDefaultBlock(); - if ( child ) { - node.replaceChild( fixer, child ); - } - else { - node.appendChild( fixer ); - } - node = fixer; - fixer = null; - } - } - - if ( node.nodeType === TEXT_NODE ) { - return originalNode; - } - - if ( isInline( node ) ) { - child = node.firstChild; - while ( cantFocusEmptyTextNodes && child && - child.nodeType === TEXT_NODE && !child.data ) { - node.removeChild( child ); - child = node.firstChild; - } - if ( !child ) { - if ( cantFocusEmptyTextNodes ) { - fixer = doc.createTextNode( ZWS ); - self._didAddZWS(); - } else { - fixer = doc.createTextNode( '' ); - } - } - } else if ( !node.querySelector( 'BR' ) ) { - fixer = createElement( doc, 'BR' ); - while ( ( child = node.lastElementChild ) && !isInline( child ) ) { - node = child; - } - } - if ( fixer ) { - try { - node.appendChild( fixer ); - } catch ( error ) { - self.didError({ - name: 'Squire: fixCursor – ' + error, - message: 'Parent: ' + node.nodeName + '/' + node.innerHTML + - ' appendChild: ' + fixer.nodeName - }); - } - } - - return originalNode; -} - -// Recursively examine container nodes and wrap any inline children. -function fixContainer ( container, root ) { - var children = container.childNodes; - var doc = container.ownerDocument; - var wrapper = null; - var i, l, child, isBR; - - for ( i = 0, l = children.length; i < l; i += 1 ) { - child = children[i]; - isBR = child.nodeName === 'BR'; - if ( !isBR && isInline( child ) ) { - if ( !wrapper ) { - wrapper = createElement( doc, 'div' ); - } - wrapper.appendChild( child ); - i -= 1; - l -= 1; - } else if ( isBR || wrapper ) { - if ( !wrapper ) { - wrapper = createElement( doc, 'div' ); - } - fixCursor( wrapper, root ); - if ( isBR ) { - container.replaceChild( wrapper, child ); - } else { - container.insertBefore( wrapper, child ); - i += 1; - l += 1; - } - wrapper = null; - } - if ( isContainer( child ) ) { - fixContainer( child, root ); - } - } - if ( wrapper ) { - container.appendChild( fixCursor( wrapper, root ) ); - } - return container; -} - -function split ( node, offset, stopNode, root ) { - var nodeType = node.nodeType, - parent, clone, next; - if ( nodeType === TEXT_NODE && node !== stopNode ) { - return split( - node.parentNode, node.splitText( offset ), stopNode, root ); - } - if ( nodeType === ELEMENT_NODE ) { - if ( typeof( offset ) === 'number' ) { - offset = offset < node.childNodes.length ? - node.childNodes[ offset ] : null; - } - if ( node === stopNode ) { - return offset; - } - - // Clone node without children - parent = node.parentNode; - clone = node.cloneNode( false ); - - // Add right-hand siblings to the clone - while ( offset ) { - next = offset.nextSibling; - clone.appendChild( offset ); - offset = next; - } - - // Maintain li numbering if inside a quote. - if ( node.nodeName === 'OL' && - getNearest( node, root, 'BLOCKQUOTE' ) ) { - clone.start = ( +node.start || 1 ) + node.childNodes.length - 1; - } - - // DO NOT NORMALISE. This may undo the fixCursor() call - // of a node lower down the tree! - - // We need something in the element in order for the cursor to appear. - fixCursor( node, root ); - fixCursor( clone, root ); - - // Inject clone after original node - if ( next = node.nextSibling ) { - parent.insertBefore( clone, next ); - } else { - parent.appendChild( clone ); - } - - // Keep on splitting up the tree - return split( parent, clone, stopNode, root ); - } - return offset; -} - -function _mergeInlines ( node, fakeRange ) { - var children = node.childNodes, - l = children.length, - frags = [], - child, prev, len; - while ( l-- ) { - child = children[l]; - prev = l && children[ l - 1 ]; - if ( l && isInline( child ) && areAlike( child, prev ) && - !leafNodeNames[ child.nodeName ] ) { - if ( fakeRange.startContainer === child ) { - fakeRange.startContainer = prev; - fakeRange.startOffset += getLength( prev ); - } - if ( fakeRange.endContainer === child ) { - fakeRange.endContainer = prev; - fakeRange.endOffset += getLength( prev ); - } - if ( fakeRange.startContainer === node ) { - if ( fakeRange.startOffset > l ) { - fakeRange.startOffset -= 1; - } - else if ( fakeRange.startOffset === l ) { - fakeRange.startContainer = prev; - fakeRange.startOffset = getLength( prev ); - } - } - if ( fakeRange.endContainer === node ) { - if ( fakeRange.endOffset > l ) { - fakeRange.endOffset -= 1; - } - else if ( fakeRange.endOffset === l ) { - fakeRange.endContainer = prev; - fakeRange.endOffset = getLength( prev ); - } - } - detach( child ); - if ( child.nodeType === TEXT_NODE ) { - prev.appendData( child.data ); - } - else { - frags.push( empty( child ) ); - } - } - else if ( child.nodeType === ELEMENT_NODE ) { - len = frags.length; - while ( len-- ) { - child.appendChild( frags.pop() ); - } - _mergeInlines( child, fakeRange ); - } - } -} - -function mergeInlines ( node, range ) { - if ( node.nodeType === TEXT_NODE ) { - node = node.parentNode; - } - if ( node.nodeType === ELEMENT_NODE ) { - var fakeRange = { - startContainer: range.startContainer, - startOffset: range.startOffset, - endContainer: range.endContainer, - endOffset: range.endOffset - }; - _mergeInlines( node, fakeRange ); - range.setStart( fakeRange.startContainer, fakeRange.startOffset ); - range.setEnd( fakeRange.endContainer, fakeRange.endOffset ); - } -} - -function mergeWithBlock ( block, next, range, root ) { - var container = next; - var parent, last, offset; - while ( ( parent = container.parentNode ) && - parent !== root && - parent.nodeType === ELEMENT_NODE && - parent.childNodes.length === 1 ) { - container = parent; - } - detach( container ); - - offset = block.childNodes.length; - - // Remove extra
fixer if present. - last = block.lastChild; - if ( last && last.nodeName === 'BR' ) { - block.removeChild( last ); - offset -= 1; - } - - block.appendChild( empty( next ) ); - - range.setStart( block, offset ); - range.collapse( true ); - mergeInlines( block, range ); -} - -function mergeContainers ( node, root ) { - var prev = node.previousSibling, - first = node.firstChild, - doc = node.ownerDocument, - isListItem = ( node.nodeName === 'LI' ), - needsFix, block; - - // Do not merge LIs, unless it only contains a UL - if ( isListItem && ( !first || !/^[OU]L$/.test( first.nodeName ) ) ) { - return; - } - - if ( prev && areAlike( prev, node ) ) { - if ( !isContainer( prev ) ) { - if ( isListItem ) { - block = createElement( doc, 'DIV' ); - block.appendChild( empty( prev ) ); - prev.appendChild( block ); - } else { - return; - } - } - detach( node ); - needsFix = !isContainer( node ); - prev.appendChild( empty( node ) ); - if ( needsFix ) { - fixContainer( prev, root ); - } - if ( first ) { - mergeContainers( first, root ); - } - } else if ( isListItem ) { - prev = createElement( doc, 'DIV' ); - node.insertBefore( prev, first ); - fixCursor( prev, root ); - } -} - -var getNodeBefore = function ( node, offset ) { - var children = node.childNodes; - while ( offset && node.nodeType === ELEMENT_NODE ) { - node = children[ offset - 1 ]; - children = node.childNodes; - offset = children.length; - } - return node; -}; - -var getNodeAfter = function ( node, offset ) { - if ( node.nodeType === ELEMENT_NODE ) { - var children = node.childNodes; - if ( offset < children.length ) { - node = children[ offset ]; - } else { - while ( node && !node.nextSibling ) { - node = node.parentNode; - } - if ( node ) { node = node.nextSibling; } - } - } - return node; -}; - -// --- - -var insertNodeInRange = function ( range, node ) { - // Insert at start. - var startContainer = range.startContainer, - startOffset = range.startOffset, - endContainer = range.endContainer, - endOffset = range.endOffset, - parent, children, childCount, afterSplit; - - // If part way through a text node, split it. - if ( startContainer.nodeType === TEXT_NODE ) { - parent = startContainer.parentNode; - children = parent.childNodes; - if ( startOffset === startContainer.length ) { - startOffset = indexOf.call( children, startContainer ) + 1; - if ( range.collapsed ) { - endContainer = parent; - endOffset = startOffset; - } - } else { - if ( startOffset ) { - afterSplit = startContainer.splitText( startOffset ); - if ( endContainer === startContainer ) { - endOffset -= startOffset; - endContainer = afterSplit; - } - else if ( endContainer === parent ) { - endOffset += 1; - } - startContainer = afterSplit; - } - startOffset = indexOf.call( children, startContainer ); - } - startContainer = parent; - } else { - children = startContainer.childNodes; - } - - childCount = children.length; - - if ( startOffset === childCount ) { - startContainer.appendChild( node ); - } else { - startContainer.insertBefore( node, children[ startOffset ] ); - } - - if ( startContainer === endContainer ) { - endOffset += children.length - childCount; - } - - range.setStart( startContainer, startOffset ); - range.setEnd( endContainer, endOffset ); -}; - -var extractContentsOfRange = function ( range, common, root ) { - var startContainer = range.startContainer, - startOffset = range.startOffset, - endContainer = range.endContainer, - endOffset = range.endOffset; - - if ( !common ) { - common = range.commonAncestorContainer; - } - - if ( common.nodeType === TEXT_NODE ) { - common = common.parentNode; - } - - var endNode = split( endContainer, endOffset, common, root ), - startNode = split( startContainer, startOffset, common, root ), - frag = common.ownerDocument.createDocumentFragment(), - next, before, after, beforeText, afterText; - - // End node will be null if at end of child nodes list. - while ( startNode !== endNode ) { - next = startNode.nextSibling; - frag.appendChild( startNode ); - startNode = next; - } - - startContainer = common; - startOffset = endNode ? - indexOf.call( common.childNodes, endNode ) : - common.childNodes.length; - - // Merge text nodes if adjacent. IE10 in particular will not focus - // between two text nodes - after = common.childNodes[ startOffset ]; - before = after && after.previousSibling; - if ( before && - before.nodeType === TEXT_NODE && - after.nodeType === TEXT_NODE ) { - startContainer = before; - startOffset = before.length; - beforeText = before.data; - afterText = after.data; - - // If we now have two adjacent spaces, the second one needs to become - // a nbsp, otherwise the browser will swallow it due to HTML whitespace - // collapsing. - if ( beforeText.charAt( beforeText.length - 1 ) === ' ' && - afterText.charAt( 0 ) === ' ' ) { - afterText = ' ' + afterText.slice( 1 ); // nbsp - } - before.appendData( afterText ); - detach( after ); - } - - range.setStart( startContainer, startOffset ); - range.collapse( true ); - - fixCursor( common, root ); - - return frag; -}; - -var deleteContentsOfRange = function ( range, root ) { - var startBlock = getStartBlockOfRange( range, root ); - var endBlock = getEndBlockOfRange( range, root ); - var needsMerge = ( startBlock !== endBlock ); - var frag, child; - - // Move boundaries up as much as possible without exiting block, - // to reduce need to split. - moveRangeBoundariesDownTree( range ); - moveRangeBoundariesUpTree( range, startBlock, endBlock, root ); - - // Remove selected range - frag = extractContentsOfRange( range, null, root ); - - // Move boundaries back down tree as far as possible. - moveRangeBoundariesDownTree( range ); - - // If we split into two different blocks, merge the blocks. - if ( needsMerge ) { - // endBlock will have been split, so need to refetch - endBlock = getEndBlockOfRange( range, root ); - if ( startBlock && endBlock && startBlock !== endBlock ) { - mergeWithBlock( startBlock, endBlock, range, root ); - } - } - - // Ensure block has necessary children - if ( startBlock ) { - fixCursor( startBlock, root ); - } - - // Ensure root has a block-level element in it. - child = root.firstChild; - if ( !child || child.nodeName === 'BR' ) { - fixCursor( root, root ); - range.selectNodeContents( root.firstChild ); - } else { - range.collapse( true ); - } - return frag; -}; - -// --- - -// Contents of range will be deleted. -// After method, range will be around inserted content -var insertTreeFragmentIntoRange = function ( range, frag, root ) { - var firstInFragIsInline = frag.firstChild && isInline( frag.firstChild ); - var node, block, blockContentsAfterSplit, stopPoint, container, offset; - var replaceBlock, firstBlockInFrag, nodeAfterSplit, nodeBeforeSplit; - var tempRange; - - // Fixup content: ensure no top-level inline, and add cursor fix elements. - fixContainer( frag, root ); - node = frag; - while ( ( node = getNextBlock( node, root ) ) ) { - fixCursor( node, root ); - } - - // Delete any selected content. - if ( !range.collapsed ) { - deleteContentsOfRange( range, root ); - } - - // Move range down into text nodes. - moveRangeBoundariesDownTree( range ); - range.collapse( false ); // collapse to end - - // Where will we split up to? First blockquote parent, otherwise root. - stopPoint = getNearest( range.endContainer, root, 'BLOCKQUOTE' ) || root; - - // Merge the contents of the first block in the frag with the focused block. - // If there are contents in the block after the focus point, collect this - // up to insert in the last block later. This preserves the style that was - // present in this bit of the page. - // - // If the block being inserted into is empty though, replace it instead of - // merging if the fragment had block contents. - // e.g.

Foo

- // This seems a reasonable approximation of user intent. - - block = getStartBlockOfRange( range, root ); - firstBlockInFrag = getNextBlock( frag, frag ); - replaceBlock = !firstInFragIsInline && !!block && isEmptyBlock( block ); - if ( block && firstBlockInFrag && !replaceBlock && - // Don't merge table cells or PRE elements into block - !getNearest( firstBlockInFrag, frag, 'PRE' ) && - !getNearest( firstBlockInFrag, frag, 'TABLE' ) ) { - moveRangeBoundariesUpTree( range, block, block, root ); - range.collapse( true ); // collapse to start - container = range.endContainer; - offset = range.endOffset; - // Remove trailing
– we don't want this considered content to be - // inserted again later - cleanupBRs( block, root, false ); - if ( isInline( container ) ) { - // Split up to block parent. - nodeAfterSplit = split( - container, offset, getPreviousBlock( container, root ), root ); - container = nodeAfterSplit.parentNode; - offset = indexOf.call( container.childNodes, nodeAfterSplit ); - } - if ( /*isBlock( container ) && */offset !== getLength( container ) ) { - // Collect any inline contents of the block after the range point - blockContentsAfterSplit = - root.ownerDocument.createDocumentFragment(); - while ( ( node = container.childNodes[ offset ] ) ) { - blockContentsAfterSplit.appendChild( node ); - } - } - // And merge the first block in. - mergeWithBlock( container, firstBlockInFrag, range, root ); - - // And where we will insert - offset = indexOf.call( container.parentNode.childNodes, container ) + 1; - container = container.parentNode; - range.setEnd( container, offset ); - } - - // Is there still any content in the fragment? - if ( getLength( frag ) ) { - if ( replaceBlock ) { - range.setEndBefore( block ); - range.collapse( false ); - detach( block ); - } - moveRangeBoundariesUpTree( range, stopPoint, stopPoint, root ); - // Now split after block up to blockquote (if a parent) or root - nodeAfterSplit = split( - range.endContainer, range.endOffset, stopPoint, root ); - nodeBeforeSplit = nodeAfterSplit ? - nodeAfterSplit.previousSibling : - stopPoint.lastChild; - stopPoint.insertBefore( frag, nodeAfterSplit ); - if ( nodeAfterSplit ) { - range.setEndBefore( nodeAfterSplit ); - } else { - range.setEnd( stopPoint, getLength( stopPoint ) ); - } - block = getEndBlockOfRange( range, root ); - - // Get a reference that won't be invalidated if we merge containers. - moveRangeBoundariesDownTree( range ); - container = range.endContainer; - offset = range.endOffset; - - // Merge inserted containers with edges of split - if ( nodeAfterSplit && isContainer( nodeAfterSplit ) ) { - mergeContainers( nodeAfterSplit, root ); - } - nodeAfterSplit = nodeBeforeSplit && nodeBeforeSplit.nextSibling; - if ( nodeAfterSplit && isContainer( nodeAfterSplit ) ) { - mergeContainers( nodeAfterSplit, root ); - } - range.setEnd( container, offset ); - } - - // Insert inline content saved from before. - if ( blockContentsAfterSplit ) { - tempRange = range.cloneRange(); - mergeWithBlock( block, blockContentsAfterSplit, tempRange, root ); - range.setEnd( tempRange.endContainer, tempRange.endOffset ); - } - moveRangeBoundariesDownTree( range ); -}; - -// --- - -var isNodeContainedInRange = function ( range, node, partial ) { - var nodeRange = node.ownerDocument.createRange(); - - nodeRange.selectNode( node ); - - if ( partial ) { - // Node must not finish before range starts or start after range - // finishes. - var nodeEndBeforeStart = ( range.compareBoundaryPoints( - END_TO_START, nodeRange ) > -1 ), - nodeStartAfterEnd = ( range.compareBoundaryPoints( - START_TO_END, nodeRange ) < 1 ); - return ( !nodeEndBeforeStart && !nodeStartAfterEnd ); - } - else { - // Node must start after range starts and finish before range - // finishes - var nodeStartAfterStart = ( range.compareBoundaryPoints( - START_TO_START, nodeRange ) < 1 ), - nodeEndBeforeEnd = ( range.compareBoundaryPoints( - END_TO_END, nodeRange ) > -1 ); - return ( nodeStartAfterStart && nodeEndBeforeEnd ); - } -}; - -var moveRangeBoundariesDownTree = function ( range ) { - var startContainer = range.startContainer, - startOffset = range.startOffset, - endContainer = range.endContainer, - endOffset = range.endOffset, - maySkipBR = true, - child; - - while ( startContainer.nodeType !== TEXT_NODE ) { - child = startContainer.childNodes[ startOffset ]; - if ( !child || isLeaf( child ) ) { - break; - } - startContainer = child; - startOffset = 0; - } - if ( endOffset ) { - while ( endContainer.nodeType !== TEXT_NODE ) { - child = endContainer.childNodes[ endOffset - 1 ]; - if ( !child || isLeaf( child ) ) { - if ( maySkipBR && child && child.nodeName === 'BR' ) { - endOffset -= 1; - maySkipBR = false; - continue; - } - break; - } - endContainer = child; - endOffset = getLength( endContainer ); - } - } else { - while ( endContainer.nodeType !== TEXT_NODE ) { - child = endContainer.firstChild; - if ( !child || isLeaf( child ) ) { - break; - } - endContainer = child; - } - } - - // If collapsed, this algorithm finds the nearest text node positions - // *outside* the range rather than inside, but also it flips which is - // assigned to which. - if ( range.collapsed ) { - range.setStart( endContainer, endOffset ); - range.setEnd( startContainer, startOffset ); - } else { - range.setStart( startContainer, startOffset ); - range.setEnd( endContainer, endOffset ); - } -}; - -var moveRangeBoundariesUpTree = function ( range, startMax, endMax, root ) { - var startContainer = range.startContainer; - var startOffset = range.startOffset; - var endContainer = range.endContainer; - var endOffset = range.endOffset; - var maySkipBR = true; - var parent; - - if ( !startMax ) { - startMax = range.commonAncestorContainer; - } - if ( !endMax ) { - endMax = startMax; - } - - while ( !startOffset && - startContainer !== startMax && - startContainer !== root ) { - parent = startContainer.parentNode; - startOffset = indexOf.call( parent.childNodes, startContainer ); - startContainer = parent; - } - - while ( true ) { - if ( endContainer === endMax || endContainer === root ) { - break; - } - if ( maySkipBR && - endContainer.nodeType !== TEXT_NODE && - endContainer.childNodes[ endOffset ] && - endContainer.childNodes[ endOffset ].nodeName === 'BR' ) { - endOffset += 1; - maySkipBR = false; - } - if ( endOffset !== getLength( endContainer ) ) { - break; - } - parent = endContainer.parentNode; - endOffset = indexOf.call( parent.childNodes, endContainer ) + 1; - endContainer = parent; - } - - range.setStart( startContainer, startOffset ); - range.setEnd( endContainer, endOffset ); -}; - -var moveRangeBoundaryOutOf = function ( range, nodeName, root ) { - var parent = getNearest( range.endContainer, root, 'A' ); - if ( parent ) { - var clone = range.cloneRange(); - parent = parent.parentNode; - moveRangeBoundariesUpTree( clone, parent, parent, root ); - if ( clone.endContainer === parent ) { - range.setStart( clone.endContainer, clone.endOffset ); - range.setEnd( clone.endContainer, clone.endOffset ); - } - } - return range; -}; - -// Returns the first block at least partially contained by the range, -// or null if no block is contained by the range. -var getStartBlockOfRange = function ( range, root ) { - var container = range.startContainer, - block; - - // If inline, get the containing block. - if ( isInline( container ) ) { - block = getPreviousBlock( container, root ); - } else if ( container !== root && isBlock( container ) ) { - block = container; - } else { - block = getNodeBefore( container, range.startOffset ); - block = getNextBlock( block, root ); - } - // Check the block actually intersects the range - return block && isNodeContainedInRange( range, block, true ) ? block : null; -}; - -// Returns the last block at least partially contained by the range, -// or null if no block is contained by the range. -var getEndBlockOfRange = function ( range, root ) { - var container = range.endContainer, - block, child; - - // If inline, get the containing block. - if ( isInline( container ) ) { - block = getPreviousBlock( container, root ); - } else if ( container !== root && isBlock( container ) ) { - block = container; - } else { - block = getNodeAfter( container, range.endOffset ); - if ( !block || !isOrContains( root, block ) ) { - block = root; - while ( child = block.lastChild ) { - block = child; - } - } - block = getPreviousBlock( block, root ); - } - // Check the block actually intersects the range - return block && isNodeContainedInRange( range, block, true ) ? block : null; -}; - -var contentWalker = new TreeWalker( null, - SHOW_TEXT|SHOW_ELEMENT, - function ( node ) { - return node.nodeType === TEXT_NODE ? - notWS.test( node.data ) : - node.nodeName === 'IMG'; - } -); - -var rangeDoesStartAtBlockBoundary = function ( range, root ) { - var startContainer = range.startContainer; - var startOffset = range.startOffset; - var nodeAfterCursor; - - // If in the middle or end of a text node, we're not at the boundary. - contentWalker.root = null; - if ( startContainer.nodeType === TEXT_NODE ) { - if ( startOffset ) { - return false; - } - nodeAfterCursor = startContainer; - } else { - nodeAfterCursor = getNodeAfter( startContainer, startOffset ); - if ( nodeAfterCursor && !isOrContains( root, nodeAfterCursor ) ) { - nodeAfterCursor = null; - } - // The cursor was right at the end of the document - if ( !nodeAfterCursor ) { - nodeAfterCursor = getNodeBefore( startContainer, startOffset ); - if ( nodeAfterCursor.nodeType === TEXT_NODE && - nodeAfterCursor.length ) { - return false; - } - } - } - - // Otherwise, look for any previous content in the same block. - contentWalker.currentNode = nodeAfterCursor; - contentWalker.root = getStartBlockOfRange( range, root ); - - return !contentWalker.previousNode(); -}; - -var rangeDoesEndAtBlockBoundary = function ( range, root ) { - var endContainer = range.endContainer, - endOffset = range.endOffset, - length; - - // If in a text node with content, and not at the end, we're not - // at the boundary - contentWalker.root = null; - if ( endContainer.nodeType === TEXT_NODE ) { - length = endContainer.data.length; - if ( length && endOffset < length ) { - return false; - } - contentWalker.currentNode = endContainer; - } else { - contentWalker.currentNode = getNodeBefore( endContainer, endOffset ); - } - - // Otherwise, look for any further content in the same block. - contentWalker.root = getEndBlockOfRange( range, root ); - - return !contentWalker.nextNode(); -}; - -var expandRangeToBlockBoundaries = function ( range, root ) { - var start = getStartBlockOfRange( range, root ), - end = getEndBlockOfRange( range, root ), - parent; - - if ( start && end ) { - parent = start.parentNode; - range.setStart( parent, indexOf.call( parent.childNodes, start ) ); - parent = end.parentNode; - range.setEnd( parent, indexOf.call( parent.childNodes, end ) + 1 ); - } -}; - -var keys = { - 8: 'backspace', - 9: 'tab', - 13: 'enter', - 32: 'space', - 33: 'pageup', - 34: 'pagedown', - 37: 'left', - 39: 'right', - 46: 'delete', - 191: '/', - 219: '[', - 220: '\\', - 221: ']' -}; - -// Ref: http://unixpapa.com/js/key.html -var onKey = function ( event ) { - var code = event.keyCode, - key = keys[ code ], - modifiers = '', - range = this.getSelection(); - - if ( event.defaultPrevented ) { - return; - } - - if ( !key ) { - key = String.fromCharCode( code ).toLowerCase(); - // Only reliable for letters and numbers - if ( !/^[A-Za-z0-9]$/.test( key ) ) { - key = ''; - } - } - - // Function keys - if ( 111 < code && code < 124 ) { - key = 'f' + ( code - 111 ); - } - - // We need to apply the backspace/delete handlers regardless of - // control key modifiers. - if ( key !== 'backspace' && key !== 'delete' ) { - if ( event.altKey ) { modifiers += 'alt-'; } - if ( event.ctrlKey ) { modifiers += 'ctrl-'; } - if ( event.metaKey ) { modifiers += 'meta-'; } - if ( event.shiftKey ) { modifiers += 'shift-'; } - } - // However, on Windows, shift-delete is apparently "cut" (WTF right?), so - // we want to let the browser handle shift-delete in this situation. - if ( isWin && event.shiftKey && key === 'delete' ) { - modifiers += 'shift-'; - } - - key = modifiers + key; - - if ( this._keyHandlers[ key ] ) { - this._keyHandlers[ key ]( this, event, range ); - // !event.isComposing stops us from blatting Kana-Kanji conversion in Safari - } else if ( !range.collapsed && !event.isComposing && - !event.ctrlKey && !event.metaKey && - ( event.key || key ).length === 1 ) { - // Record undo checkpoint. - this.saveUndoState( range ); - // Delete the selection - deleteContentsOfRange( range, this._root ); - this._ensureBottomLine(); - this.setSelection( range ); - this._updatePath( range, true ); - } -}; - -var mapKeyTo = function ( method ) { - return function ( self, event ) { - event.preventDefault(); - self[ method ](); - }; -}; - -var mapKeyToFormat = function ( tag, remove ) { - remove = remove || null; - return function ( self, event ) { - event.preventDefault(); - var range = self.getSelection(); - if ( self.hasFormat( tag, null, range ) ) { - self.changeFormat( null, { tag: tag }, range ); - } else { - self.changeFormat( { tag: tag }, remove, range ); - } - }; -}; - -// If you delete the content inside a span with a font styling, Webkit will -// replace it with a tag (!). If you delete all the text inside a -// link in Opera, it won't delete the link. Let's make things consistent. If -// you delete all text inside an inline tag, remove the inline tag. -var afterDelete = function ( self, range ) { - try { - if ( !range ) { range = self.getSelection(); } - var node = range.startContainer, - parent; - // Climb the tree from the focus point while we are inside an empty - // inline element - if ( node.nodeType === TEXT_NODE ) { - node = node.parentNode; - } - parent = node; - while ( isInline( parent ) && - ( !parent.textContent || parent.textContent === ZWS ) ) { - node = parent; - parent = node.parentNode; - } - // If focused in empty inline element - if ( node !== parent ) { - // Move focus to just before empty inline(s) - range.setStart( parent, - indexOf.call( parent.childNodes, node ) ); - range.collapse( true ); - // Remove empty inline(s) - parent.removeChild( node ); - // Fix cursor in block - if ( !isBlock( parent ) ) { - parent = getPreviousBlock( parent, self._root ); - } - fixCursor( parent, self._root ); - // Move cursor into text node - moveRangeBoundariesDownTree( range ); - } - // If you delete the last character in the sole
in Chrome, - // it removes the div and replaces it with just a
inside the - // root. Detach the
; the _ensureBottomLine call will insert a new - // block. - if ( node === self._root && - ( node = node.firstChild ) && node.nodeName === 'BR' ) { - detach( node ); - } - self._ensureBottomLine(); - self.setSelection( range ); - self._updatePath( range, true ); - } catch ( error ) { - self.didError( error ); - } -}; - -var detachUneditableNode = function ( node, root ) { - var parent; - while (( parent = node.parentNode )) { - if ( parent === root || parent.isContentEditable ) { - break; - } - node = parent; - } - detach( node ); -}; - -var handleEnter = function ( self, shiftKey, range ) { - var root = self._root; - var block, parent, node, offset, nodeAfterSplit; - - // Save undo checkpoint and add any links in the preceding section. - // Remove any zws so we don't think there's content in an empty - // block. - self._recordUndoState( range ); - if ( self._config.addLinks ) { - addLinks( range.startContainer, root, self ); - } - self._removeZWS(); - self._getRangeAndRemoveBookmark( range ); - - // Selected text is overwritten, therefore delete the contents - // to collapse selection. - if ( !range.collapsed ) { - deleteContentsOfRange( range, root ); - } - - block = getStartBlockOfRange( range, root ); - - // Inside a PRE, insert literal newline, unless on blank line. - if ( block && ( parent = getNearest( block, root, 'PRE' ) ) ) { - moveRangeBoundariesDownTree( range ); - node = range.startContainer; - offset = range.startOffset; - if ( node.nodeType !== TEXT_NODE ) { - node = self._doc.createTextNode( '' ); - parent.insertBefore( node, parent.firstChild ); - } - // If blank line: split and insert default block - if ( !shiftKey && - ( node.data.charAt( offset - 1 ) === '\n' || - rangeDoesStartAtBlockBoundary( range, root ) ) && - ( node.data.charAt( offset ) === '\n' || - rangeDoesEndAtBlockBoundary( range, root ) ) ) { - node.deleteData( offset && offset - 1, offset ? 2 : 1 ); - nodeAfterSplit = - split( node, offset && offset - 1, root, root ); - node = nodeAfterSplit.previousSibling; - if ( !node.textContent ) { - detach( node ); - } - node = self.createDefaultBlock(); - nodeAfterSplit.parentNode.insertBefore( node, nodeAfterSplit ); - if ( !nodeAfterSplit.textContent ) { - detach( nodeAfterSplit ); - } - range.setStart( node, 0 ); - } else { - node.insertData( offset, '\n' ); - fixCursor( parent, root ); - // Firefox bug: if you set the selection in the text node after - // the new line, it draws the cursor before the line break still - // but if you set the selection to the equivalent position - // in the parent, it works. - if ( node.length === offset + 1 ) { - range.setStartAfter( node ); - } else { - range.setStart( node, offset + 1 ); - } - } - range.collapse( true ); - self.setSelection( range ); - self._updatePath( range, true ); - self._docWasChanged(); - return; - } - - // If this is a malformed bit of document or in a table; - // just play it safe and insert a
. - if ( !block || shiftKey || /^T[HD]$/.test( block.nodeName ) ) { - // If inside an
, move focus out - moveRangeBoundaryOutOf( range, 'A', root ); - insertNodeInRange( range, self.createElement( 'BR' ) ); - range.collapse( false ); - self.setSelection( range ); - self._updatePath( range, true ); - return; - } - - // If in a list, we'll split the LI instead. - if ( parent = getNearest( block, root, 'LI' ) ) { - block = parent; - } - - if ( isEmptyBlock( block ) ) { - // Break list - if ( getNearest( block, root, 'UL' ) || - getNearest( block, root, 'OL' ) ) { - return self.decreaseListLevel( range ); - } - // Break blockquote - else if ( getNearest( block, root, 'BLOCKQUOTE' ) ) { - return self.modifyBlocks( removeBlockQuote, range ); - } - } - - // Otherwise, split at cursor point. - nodeAfterSplit = splitBlock( self, block, - range.startContainer, range.startOffset ); - - // Clean up any empty inlines if we hit enter at the beginning of the - // block - removeZWS( block ); - removeEmptyInlines( block ); - fixCursor( block, root ); - - // Focus cursor - // If there's a / etc. at the beginning of the split - // make sure we focus inside it. - while ( nodeAfterSplit.nodeType === ELEMENT_NODE ) { - var child = nodeAfterSplit.firstChild, - next; - - // Don't continue links over a block break; unlikely to be the - // desired outcome. - if ( nodeAfterSplit.nodeName === 'A' && - ( !nodeAfterSplit.textContent || - nodeAfterSplit.textContent === ZWS ) ) { - child = self._doc.createTextNode( '' ); - replaceWith( nodeAfterSplit, child ); - nodeAfterSplit = child; - break; - } - - while ( child && child.nodeType === TEXT_NODE && !child.data ) { - next = child.nextSibling; - if ( !next || next.nodeName === 'BR' ) { - break; - } - detach( child ); - child = next; - } - - // 'BR's essentially don't count; they're a browser hack. - // If you try to select the contents of a 'BR', FF will not let - // you type anything! - if ( !child || child.nodeName === 'BR' || - child.nodeType === TEXT_NODE ) { - break; - } - nodeAfterSplit = child; - } - range = self.createRange( nodeAfterSplit, 0 ); - self.setSelection( range ); - self._updatePath( range, true ); -}; - -var keyHandlers = { - // This song and dance is to force iOS to do enable the shift key - // automatically on enter. When you do the DOM split manipulation yourself, - // WebKit doesn't reset the IME state and so presents auto-complete options - // as though you were continuing to type on the previous line, and doesn't - // auto-enable the shift key. The old trick of blurring and focussing - // again no longer works in iOS 13, and I tried various execCommand options - // but they didn't seem to do anything. The only solution I've found is to - // let iOS handle the enter key, then after it's done that reset the HTML - // to what it was before and handle it properly in Squire; the IME state of - // course doesn't reset so you end up in the correct state! - enter: isIOS ? function ( self, event, range ) { - self._saveRangeToBookmark( range ); - var html = self._getHTML(); - var restoreAndDoEnter = function () { - self.removeEventListener( 'keyup', restoreAndDoEnter ); - self._setHTML( html ); - range = self._getRangeAndRemoveBookmark(); - // Ignore the shift key on iOS, as this is for auto-capitalisation. - handleEnter( self, false, range ); - }; - self.addEventListener( 'keyup', restoreAndDoEnter ); - } : function ( self, event, range ) { - event.preventDefault(); - handleEnter( self, event.shiftKey, range ); - }, - - 'shift-enter': function ( self, event, range ) { - return self._keyHandlers.enter( self, event, range ); - }, - - backspace: function ( self, event, range ) { - var root = self._root; - self._removeZWS(); - // Record undo checkpoint. - self.saveUndoState( range ); - // If not collapsed, delete contents - if ( !range.collapsed ) { - event.preventDefault(); - deleteContentsOfRange( range, root ); - afterDelete( self, range ); - } - // If at beginning of block, merge with previous - else if ( rangeDoesStartAtBlockBoundary( range, root ) ) { - event.preventDefault(); - var current = getStartBlockOfRange( range, root ); - var previous; - if ( !current ) { - return; - } - // In case inline data has somehow got between blocks. - fixContainer( current.parentNode, root ); - // Now get previous block - previous = getPreviousBlock( current, root ); - // Must not be at the very beginning of the text area. - if ( previous ) { - // If not editable, just delete whole block. - if ( !previous.isContentEditable ) { - detachUneditableNode( previous, root ); - return; - } - // Otherwise merge. - mergeWithBlock( previous, current, range, root ); - // If deleted line between containers, merge newly adjacent - // containers. - current = previous.parentNode; - while ( current !== root && !current.nextSibling ) { - current = current.parentNode; - } - if ( current !== root && ( current = current.nextSibling ) ) { - mergeContainers( current, root ); - } - self.setSelection( range ); - } - // If at very beginning of text area, allow backspace - // to break lists/blockquote. - else if ( current ) { - // Break list - if ( getNearest( current, root, 'UL' ) || - getNearest( current, root, 'OL' ) ) { - return self.decreaseListLevel( range ); - } - // Break blockquote - else if ( getNearest( current, root, 'BLOCKQUOTE' ) ) { - return self.modifyBlocks( decreaseBlockQuoteLevel, range ); - } - self.setSelection( range ); - self._updatePath( range, true ); - } - } - // Otherwise, leave to browser but check afterwards whether it has - // left behind an empty inline tag. - else { - self.setSelection( range ); - setTimeout( function () { afterDelete( self ); }, 0 ); - } - }, - 'delete': function ( self, event, range ) { - var root = self._root; - var current, next, originalRange, - cursorContainer, cursorOffset, nodeAfterCursor; - self._removeZWS(); - // Record undo checkpoint. - self.saveUndoState( range ); - // If not collapsed, delete contents - if ( !range.collapsed ) { - event.preventDefault(); - deleteContentsOfRange( range, root ); - afterDelete( self, range ); - } - // If at end of block, merge next into this block - else if ( rangeDoesEndAtBlockBoundary( range, root ) ) { - event.preventDefault(); - current = getStartBlockOfRange( range, root ); - if ( !current ) { - return; - } - // In case inline data has somehow got between blocks. - fixContainer( current.parentNode, root ); - // Now get next block - next = getNextBlock( current, root ); - // Must not be at the very end of the text area. - if ( next ) { - // If not editable, just delete whole block. - if ( !next.isContentEditable ) { - detachUneditableNode( next, root ); - return; - } - // Otherwise merge. - mergeWithBlock( current, next, range, root ); - // If deleted line between containers, merge newly adjacent - // containers. - next = current.parentNode; - while ( next !== root && !next.nextSibling ) { - next = next.parentNode; - } - if ( next !== root && ( next = next.nextSibling ) ) { - mergeContainers( next, root ); - } - self.setSelection( range ); - self._updatePath( range, true ); - } - } - // Otherwise, leave to browser but check afterwards whether it has - // left behind an empty inline tag. - else { - // But first check if the cursor is just before an IMG tag. If so, - // delete it ourselves, because the browser won't if it is not - // inline. - originalRange = range.cloneRange(); - moveRangeBoundariesUpTree( range, root, root, root ); - cursorContainer = range.endContainer; - cursorOffset = range.endOffset; - if ( cursorContainer.nodeType === ELEMENT_NODE ) { - nodeAfterCursor = cursorContainer.childNodes[ cursorOffset ]; - if ( nodeAfterCursor && nodeAfterCursor.nodeName === 'IMG' ) { - event.preventDefault(); - detach( nodeAfterCursor ); - moveRangeBoundariesDownTree( range ); - afterDelete( self, range ); - return; - } - } - self.setSelection( originalRange ); - setTimeout( function () { afterDelete( self ); }, 0 ); - } - }, - tab: function ( self, event, range ) { - var root = self._root; - var node, parent; - self._removeZWS(); - // If no selection and at start of block - if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) { - node = getStartBlockOfRange( range, root ); - // Iterate through the block's parents - while ( ( parent = node.parentNode ) ) { - // If we find a UL or OL (so are in a list, node must be an LI) - if ( parent.nodeName === 'UL' || parent.nodeName === 'OL' ) { - // Then increase the list level - event.preventDefault(); - self.increaseListLevel( range ); - break; - } - node = parent; - } - } - }, - 'shift-tab': function ( self, event, range ) { - var root = self._root; - var node; - self._removeZWS(); - // If no selection and at start of block - if ( range.collapsed && rangeDoesStartAtBlockBoundary( range, root ) ) { - // Break list - node = range.startContainer; - if ( getNearest( node, root, 'UL' ) || - getNearest( node, root, 'OL' ) ) { - event.preventDefault(); - self.decreaseListLevel( range ); - } - } - }, - space: function ( self, _, range ) { - var node; - var root = self._root; - self._recordUndoState( range ); - if ( self._config.addLinks ) { - addLinks( range.startContainer, root, self ); - } - self._getRangeAndRemoveBookmark( range ); - - // If the cursor is at the end of a link (foo|) then move it - // outside of the link (foo|) so that the space is not part of - // the link text. - node = range.endContainer; - if ( range.collapsed && range.endOffset === getLength( node ) ) { - do { - if ( node.nodeName === 'A' ) { - range.setStartAfter( node ); - break; - } - } while ( !node.nextSibling && - ( node = node.parentNode ) && node !== root ); - } - // Delete the selection if not collapsed - if ( !range.collapsed ) { - deleteContentsOfRange( range, root ); - self._ensureBottomLine(); - self.setSelection( range ); - self._updatePath( range, true ); - } - - self.setSelection( range ); - }, - left: function ( self ) { - self._removeZWS(); - }, - right: function ( self ) { - self._removeZWS(); - } -}; - -// Firefox pre v29 incorrectly handles Cmd-left/Cmd-right on Mac: -// it goes back/forward in history! Override to do the right -// thing. -// https://bugzilla.mozilla.org/show_bug.cgi?id=289384 -if ( isMac && isGecko ) { - keyHandlers[ 'meta-left' ] = function ( self, event ) { - event.preventDefault(); - var sel = getWindowSelection( self ); - if ( sel && sel.modify ) { - sel.modify( 'move', 'backward', 'lineboundary' ); - } - }; - keyHandlers[ 'meta-right' ] = function ( self, event ) { - event.preventDefault(); - var sel = getWindowSelection( self ); - if ( sel && sel.modify ) { - sel.modify( 'move', 'forward', 'lineboundary' ); - } - }; -} - -// System standard for page up/down on Mac is to just scroll, not move the -// cursor. On Linux/Windows, it should move the cursor, but some browsers don't -// implement this natively. Override to support it. -if ( !isMac ) { - keyHandlers.pageup = function ( self ) { - self.moveCursorToStart(); - }; - keyHandlers.pagedown = function ( self ) { - self.moveCursorToEnd(); - }; -} - -const changeIndentationLevel = function ( methodIfInQuote, methodIfInList ) { - return function ( self, event ) { - event.preventDefault(); - var path = self.getPath(); - if ( /(?:^|>)BLOCKQUOTE/.test( path ) || - !/(?:^|>)[OU]L/.test( path ) ) { - self[ methodIfInQuote ](); - } else { - self[ methodIfInList ](); - } - }; -}; - -const toggleList = function ( listRegex, methodIfNotInList ) { - return function ( self, event ) { - event.preventDefault(); - var path = self.getPath(); - if ( !listRegex.test( path ) ) { - self[ methodIfNotInList ](); - } else { - self.removeList(); - } - }; -}; - -keyHandlers[ ctrlKey + 'b' ] = mapKeyToFormat( 'B' ); -keyHandlers[ ctrlKey + 'i' ] = mapKeyToFormat( 'I' ); -keyHandlers[ ctrlKey + 'u' ] = mapKeyToFormat( 'U' ); -keyHandlers[ ctrlKey + 'shift-7' ] = mapKeyToFormat( 'S' ); -keyHandlers[ ctrlKey + 'shift-5' ] = mapKeyToFormat( 'SUB', { tag: 'SUP' } ); -keyHandlers[ ctrlKey + 'shift-6' ] = mapKeyToFormat( 'SUP', { tag: 'SUB' } ); -keyHandlers[ ctrlKey + 'shift-8' ] = - toggleList( /(?:^|>)UL/, 'makeUnorderedList' ); -keyHandlers[ ctrlKey + 'shift-9' ] = - toggleList( /(?:^|>)OL/, 'makeOrderedList' ); -keyHandlers[ ctrlKey + '[' ] = - changeIndentationLevel( 'decreaseQuoteLevel', 'decreaseListLevel' ); -keyHandlers[ ctrlKey + ']' ] = - changeIndentationLevel( 'increaseQuoteLevel', 'increaseListLevel' ); -keyHandlers[ ctrlKey + 'd' ] = mapKeyTo( 'toggleCode' ); -keyHandlers[ ctrlKey + 'y' ] = mapKeyTo( 'redo' ); -keyHandlers[ ctrlKey + 'z' ] = mapKeyTo( 'undo' ); -keyHandlers[ ctrlKey + 'shift-z' ] = mapKeyTo( 'redo' ); - -var fontSizes = { - 1: 10, - 2: 13, - 3: 16, - 4: 18, - 5: 24, - 6: 32, - 7: 48 -}; - -var styleToSemantic = { - fontWeight: { - regexp: /^bold|^700/i, - replace: function ( doc ) { - return createElement( doc, 'B' ); - } - }, - fontStyle: { - regexp: /^italic/i, - replace: function ( doc ) { - return createElement( doc, 'I' ); - } - }, - fontFamily: { - regexp: notWS, - replace: function ( doc, classNames, family ) { - return createElement( doc, 'SPAN', { - 'class': classNames.fontFamily, - style: 'font-family:' + family - }); - } - }, - fontSize: { - regexp: notWS, - replace: function ( doc, classNames, size ) { - return createElement( doc, 'SPAN', { - 'class': classNames.fontSize, - style: 'font-size:' + size - }); - } - }, - textDecoration: { - regexp: /^underline/i, - replace: function ( doc ) { - return createElement( doc, 'U' ); - } - } -}; - -var replaceWithTag = function ( tag ) { - return function ( node, parent ) { - var el = createElement( node.ownerDocument, tag ); - var attributes = node.attributes; - var i, l, attribute; - for ( i = 0, l = attributes.length; i < l; i += 1 ) { - attribute = attributes[i]; - el.setAttribute( attribute.name, attribute.value ); - } - parent.replaceChild( el, node ); - el.appendChild( empty( node ) ); - return el; - }; -}; - -var replaceStyles = function ( node, parent, config ) { - var style = node.style; - var doc = node.ownerDocument; - var attr, converter, css, newTreeBottom, newTreeTop, el; - - for ( attr in styleToSemantic ) { - converter = styleToSemantic[ attr ]; - css = style[ attr ]; - if ( css && converter.regexp.test( css ) ) { - el = converter.replace( doc, config.classNames, css ); - if ( el.nodeName === node.nodeName && - el.className === node.className ) { - continue; - } - if ( !newTreeTop ) { - newTreeTop = el; - } - if ( newTreeBottom ) { - newTreeBottom.appendChild( el ); - } - newTreeBottom = el; - node.style[ attr ] = ''; - } - } - - if ( newTreeTop ) { - newTreeBottom.appendChild( empty( node ) ); - node.appendChild( newTreeTop ); - } - - return newTreeBottom || node; -}; - -var stylesRewriters = { - SPAN: replaceStyles, - STRONG: replaceWithTag( 'B' ), - EM: replaceWithTag( 'I' ), - INS: replaceWithTag( 'U' ), - STRIKE: replaceWithTag( 'S' ), - FONT: function ( node, parent, config ) { - var face = node.face; - var size = node.size; - var colour = node.color; - var doc = node.ownerDocument; - var classNames = config.classNames; - var fontSpan, sizeSpan, colourSpan; - var newTreeBottom, newTreeTop; - if ( face ) { - fontSpan = createElement( doc, 'SPAN', { - 'class': classNames.fontFamily, - style: 'font-family:' + face - }); - newTreeTop = fontSpan; - newTreeBottom = fontSpan; - } - if ( size ) { - sizeSpan = createElement( doc, 'SPAN', { - 'class': classNames.fontSize, - style: 'font-size:' + fontSizes[ size ] + 'px' - }); - if ( !newTreeTop ) { - newTreeTop = sizeSpan; - } - if ( newTreeBottom ) { - newTreeBottom.appendChild( sizeSpan ); - } - newTreeBottom = sizeSpan; - } - if ( colour && /^#?([\dA-F]{3}){1,2}$/i.test( colour ) ) { - if ( colour.charAt( 0 ) !== '#' ) { - colour = '#' + colour; - } - colourSpan = createElement( doc, 'SPAN', { - 'class': classNames.colour, - style: 'color:' + colour - }); - if ( !newTreeTop ) { - newTreeTop = colourSpan; - } - if ( newTreeBottom ) { - newTreeBottom.appendChild( colourSpan ); - } - newTreeBottom = colourSpan; - } - if ( !newTreeTop ) { - newTreeTop = newTreeBottom = createElement( doc, 'SPAN' ); - } - parent.replaceChild( newTreeTop, node ); - newTreeBottom.appendChild( empty( node ) ); - return newTreeBottom; - }, - TT: function ( node, parent, config ) { - var el = createElement( node.ownerDocument, 'SPAN', { - 'class': config.classNames.fontFamily, - style: 'font-family:menlo,consolas,"courier new",monospace' - }); - parent.replaceChild( el, node ); - el.appendChild( empty( node ) ); - return el; - } -}; - -var allowedBlock = /^(?:A(?:DDRESS|RTICLE|SIDE|UDIO)|BLOCKQUOTE|CAPTION|D(?:[DLT]|IV)|F(?:IGURE|IGCAPTION|OOTER)|H[1-6]|HEADER|L(?:ABEL|EGEND|I)|O(?:L|UTPUT)|P(?:RE)?|SECTION|T(?:ABLE|BODY|D|FOOT|H|HEAD|R)|COL(?:GROUP)?|UL)$/; - -var blacklist = /^(?:HEAD|META|STYLE)/; - -var walker = new TreeWalker( null, SHOW_TEXT|SHOW_ELEMENT ); - -/* - Two purposes: - - 1. Remove nodes we don't want, such as weird tags, comment nodes - and whitespace nodes. - 2. Convert inline tags into our preferred format. -*/ -var cleanTree = function cleanTree ( node, config, preserveWS ) { - var children = node.childNodes, - nonInlineParent, i, l, child, nodeName, nodeType, rewriter, childLength, - startsWithWS, endsWithWS, data, sibling; - - nonInlineParent = node; - while ( isInline( nonInlineParent ) ) { - nonInlineParent = nonInlineParent.parentNode; - } - walker.root = nonInlineParent; - - for ( i = 0, l = children.length; i < l; i += 1 ) { - child = children[i]; - nodeName = child.nodeName; - nodeType = child.nodeType; - rewriter = stylesRewriters[ nodeName ]; - if ( nodeType === ELEMENT_NODE ) { - childLength = child.childNodes.length; - if ( rewriter ) { - child = rewriter( child, node, config ); - } else if ( blacklist.test( nodeName ) ) { - node.removeChild( child ); - i -= 1; - l -= 1; - continue; - } else if ( !allowedBlock.test( nodeName ) && !isInline( child ) ) { - i -= 1; - l += childLength - 1; - node.replaceChild( empty( child ), child ); - continue; - } - if ( childLength ) { - cleanTree( child, config, - preserveWS || ( nodeName === 'PRE' ) ); - } - } else { - if ( nodeType === TEXT_NODE ) { - data = child.data; - startsWithWS = !notWS.test( data.charAt( 0 ) ); - endsWithWS = !notWS.test( data.charAt( data.length - 1 ) ); - if ( preserveWS || ( !startsWithWS && !endsWithWS ) ) { - continue; - } - // Iterate through the nodes; if we hit some other content - // before the start of a new block we don't trim - if ( startsWithWS ) { - walker.currentNode = child; - while ( sibling = walker.previousPONode() ) { - nodeName = sibling.nodeName; - if ( nodeName === 'IMG' || - ( nodeName === '#text' && - notWS.test( sibling.data ) ) ) { - break; - } - if ( !isInline( sibling ) ) { - sibling = null; - break; - } - } - data = data.replace( /^[ \r\n]+/g, sibling ? ' ' : '' ); - } - if ( endsWithWS ) { - walker.currentNode = child; - while ( sibling = walker.nextNode() ) { - if ( nodeName === 'IMG' || - ( nodeName === '#text' && - notWS.test( sibling.data ) ) ) { - break; - } - if ( !isInline( sibling ) ) { - sibling = null; - break; - } - } - data = data.replace( /[ \r\n]+$/g, sibling ? ' ' : '' ); - } - if ( data ) { - child.data = data; - continue; - } - } - node.removeChild( child ); - i -= 1; - l -= 1; - } - } - return node; -}; - -// --- - -var removeEmptyInlines = function removeEmptyInlines ( node ) { - var children = node.childNodes, - l = children.length, - child; - while ( l-- ) { - child = children[l]; - if ( child.nodeType === ELEMENT_NODE && !isLeaf( child ) ) { - removeEmptyInlines( child ); - if ( isInline( child ) && !child.firstChild ) { - node.removeChild( child ); - } - } else if ( child.nodeType === TEXT_NODE && !child.data ) { - node.removeChild( child ); - } - } -}; - -// --- - -var notWSTextNode = function ( node ) { - return node.nodeType === ELEMENT_NODE ? - node.nodeName === 'BR' : - notWS.test( node.data ); -}; -var isLineBreak = function ( br, isLBIfEmptyBlock ) { - var block = br.parentNode; - var walker; - while ( isInline( block ) ) { - block = block.parentNode; - } - walker = new TreeWalker( - block, SHOW_ELEMENT|SHOW_TEXT, notWSTextNode ); - walker.currentNode = br; - return !!walker.nextNode() || - ( isLBIfEmptyBlock && !walker.previousNode() ); -}; - -//
elements are treated specially, and differently depending on the -// browser, when in rich text editor mode. When adding HTML from external -// sources, we must remove them, replacing the ones that actually affect -// line breaks by wrapping the inline text in a
. Browsers that want
-// elements at the end of each block will then have them added back in a later -// fixCursor method call. -var cleanupBRs = function ( node, root, keepForBlankLine ) { - var brs = node.querySelectorAll( 'BR' ); - var brBreaksLine = []; - var l = brs.length; - var i, br, parent; - - // Must calculate whether the
breaks a line first, because if we - // have two
s next to each other, after the first one is converted - // to a block split, the second will be at the end of a block and - // therefore seem to not be a line break. But in its original context it - // was, so we should also convert it to a block split. - for ( i = 0; i < l; i += 1 ) { - brBreaksLine[i] = isLineBreak( brs[i], keepForBlankLine ); - } - while ( l-- ) { - br = brs[l]; - // Cleanup may have removed it - parent = br.parentNode; - if ( !parent ) { continue; } - // If it doesn't break a line, just remove it; it's not doing - // anything useful. We'll add it back later if required by the - // browser. If it breaks a line, wrap the content in div tags - // and replace the brs. - if ( !brBreaksLine[l] ) { - detach( br ); - } else if ( !isInline( parent ) ) { - fixContainer( parent, root ); - } - } -}; - -// The (non-standard but supported enough) innerText property is based on the -// render tree in Firefox and possibly other browsers, so we must insert the -// DOM node into the document to ensure the text part is correct. -var setClipboardData = - function ( event, contents, root, willCutCopy, toPlainText, plainTextOnly ) { - var clipboardData = event.clipboardData; - var doc = event.target.ownerDocument; - var body = doc.body; - var node = createElement( doc, 'div' ); - var html, text; - - node.appendChild( contents ); - - html = node.innerHTML; - if ( willCutCopy ) { - html = willCutCopy( html ); - } - - if ( toPlainText ) { - text = toPlainText( html ); - } else { - // Firefox will add an extra new line for BRs at the end of block when - // calculating innerText, even though they don't actually affect - // display, so we need to remove them first. - cleanupBRs( node, root, true ); - node.setAttribute( 'style', - 'position:fixed;overflow:hidden;bottom:100%;right:100%;' ); - body.appendChild( node ); - text = node.innerText || node.textContent; - text = text.replace( / /g, ' ' ); // Replace nbsp with regular space - body.removeChild( node ); - } - // Firefox (and others?) returns unix line endings (\n) even on Windows. - // If on Windows, normalise to \r\n, since Notepad and some other crappy - // apps do not understand just \n. - if ( isWin ) { - text = text.replace( /\r?\n/g, '\r\n' ); - } - - if ( !plainTextOnly && text !== html ) { - clipboardData.setData( 'text/html', html ); - } - clipboardData.setData( 'text/plain', text ); - event.preventDefault(); -}; - -var onCut = function ( event ) { - var range = this.getSelection(); - var root = this._root; - var self = this; - var startBlock, endBlock, copyRoot, contents, parent, newContents; - - // Nothing to do - if ( range.collapsed ) { - event.preventDefault(); - return; - } - - // Save undo checkpoint - this.saveUndoState( range ); - - // Edge only seems to support setting plain text as of 2016-03-11. - if ( !isEdge && event.clipboardData ) { - // Clipboard content should include all parents within block, or all - // parents up to root if selection across blocks - startBlock = getStartBlockOfRange( range, root ); - endBlock = getEndBlockOfRange( range, root ); - copyRoot = ( ( startBlock === endBlock ) && startBlock ) || root; - // Extract the contents - contents = deleteContentsOfRange( range, root ); - // Add any other parents not in extracted content, up to copy root - parent = range.commonAncestorContainer; - if ( parent.nodeType === TEXT_NODE ) { - parent = parent.parentNode; - } - while ( parent && parent !== copyRoot ) { - newContents = parent.cloneNode( false ); - newContents.appendChild( contents ); - contents = newContents; - parent = parent.parentNode; - } - // Set clipboard data - setClipboardData( - event, contents, root, this._config.willCutCopy, null, false ); - } else { - setTimeout( function () { - try { - // If all content removed, ensure div at start of root. - self._ensureBottomLine(); - } catch ( error ) { - self.didError( error ); - } - }, 0 ); - } - - this.setSelection( range ); -}; - -var _onCopy = function ( event, range, root, willCutCopy, toPlainText, plainTextOnly ) { - var startBlock, endBlock, copyRoot, contents, parent, newContents; - // Edge only seems to support setting plain text as of 2016-03-11. - if ( !isEdge && event.clipboardData ) { - // Clipboard content should include all parents within block, or all - // parents up to root if selection across blocks - startBlock = getStartBlockOfRange( range, root ); - endBlock = getEndBlockOfRange( range, root ); - copyRoot = ( ( startBlock === endBlock ) && startBlock ) || root; - // Clone range to mutate, then move up as high as possible without - // passing the copy root node. - range = range.cloneRange(); - moveRangeBoundariesDownTree( range ); - moveRangeBoundariesUpTree( range, copyRoot, copyRoot, root ); - // Extract the contents - contents = range.cloneContents(); - // Add any other parents not in extracted content, up to copy root - parent = range.commonAncestorContainer; - if ( parent.nodeType === TEXT_NODE ) { - parent = parent.parentNode; - } - while ( parent && parent !== copyRoot ) { - newContents = parent.cloneNode( false ); - newContents.appendChild( contents ); - contents = newContents; - parent = parent.parentNode; - } - // Set clipboard data - setClipboardData( event, contents, root, willCutCopy, toPlainText, plainTextOnly ); - } -}; - -var onCopy = function ( event ) { - _onCopy( - event, - this.getSelection(), - this._root, - this._config.willCutCopy, - null, - false - ); -}; - -// Need to monitor for shift key like this, as event.shiftKey is not available -// in paste event. -function monitorShiftKey ( event ) { - this.isShiftDown = event.shiftKey; -} - -var onPaste = function ( event ) { - var clipboardData = event.clipboardData; - var items = clipboardData && clipboardData.items; - var choosePlain = this.isShiftDown; - var fireDrop = false; - var hasRTF = false; - var hasImage = false; - var plainItem = null; - var htmlItem = null; - var self = this; - var l, item, type, types, data; - - // Current HTML5 Clipboard interface - // --------------------------------- - // https://html.spec.whatwg.org/multipage/interaction.html - if ( items ) { - l = items.length; - while ( l-- ) { - item = items[l]; - type = item.type; - if ( type === 'text/html' ) { - htmlItem = item; - // iOS copy URL gives you type text/uri-list which is just a list - // of 1 or more URLs separated by new lines. Can just treat as - // plain text. - } else if ( type === 'text/plain' || type === 'text/uri-list' ) { - plainItem = item; - } else if ( type === 'text/rtf' ) { - hasRTF = true; - } else if ( /^image\/.*/.test( type ) ) { - hasImage = true; - } - } - - // Treat image paste as a drop of an image file. When you copy - // an image in Chrome/Firefox (at least), it copies the image data - // but also an HTML version (referencing the original URL of the image) - // and a plain text version. - // - // However, when you copy in Excel, you get html, rtf, text, image; - // in this instance you want the html version! So let's try using - // the presence of text/rtf as an indicator to choose the html version - // over the image. - if ( hasImage && !( hasRTF && htmlItem ) ) { - event.preventDefault(); - this.fireEvent( 'dragover', { - dataTransfer: clipboardData, - /*jshint loopfunc: true */ - preventDefault: function () { - fireDrop = true; - } - /*jshint loopfunc: false */ - }); - if ( fireDrop ) { - this.fireEvent( 'drop', { - dataTransfer: clipboardData - }); - } - return; - } - - // Edge only provides access to plain text as of 2016-03-11 and gives no - // indication there should be an HTML part. However, it does support - // access to image data, so we check for that first. Otherwise though, - // fall through to fallback clipboard handling methods - if ( !isEdge ) { - event.preventDefault(); - if ( htmlItem && ( !choosePlain || !plainItem ) ) { - htmlItem.getAsString( function ( html ) { - self.insertHTML( html, true ); - }); - } else if ( plainItem ) { - plainItem.getAsString( function ( text ) { - self.insertPlainText( text, true ); - }); - } - return; - } - } - - // Old interface - // ------------- - - // Safari (and indeed many other OS X apps) copies stuff as text/rtf - // rather than text/html; even from a webpage in Safari. The only way - // to get an HTML version is to fallback to letting the browser insert - // the content. Same for getting image data. *Sigh*. - // - // Firefox is even worse: it doesn't even let you know that there might be - // an RTF version on the clipboard, but it will also convert to HTML if you - // let the browser insert the content. I've filed - // https://bugzilla.mozilla.org/show_bug.cgi?id=1254028 - types = clipboardData && clipboardData.types; - if ( !isEdge && types && ( - indexOf.call( types, 'text/html' ) > -1 || ( - !isGecko && - indexOf.call( types, 'text/plain' ) > -1 && - indexOf.call( types, 'text/rtf' ) < 0 ) - )) { - event.preventDefault(); - // Abiword on Linux copies a plain text and html version, but the HTML - // version is the empty string! So always try to get HTML, but if none, - // insert plain text instead. On iOS, Facebook (and possibly other - // apps?) copy links as type text/uri-list, but also insert a **blank** - // text/plain item onto the clipboard. Why? Who knows. - if ( !choosePlain && ( data = clipboardData.getData( 'text/html' ) ) ) { - this.insertHTML( data, true ); - } else if ( - ( data = clipboardData.getData( 'text/plain' ) ) || - ( data = clipboardData.getData( 'text/uri-list' ) ) ) { - this.insertPlainText( data, true ); - } - return; - } - - // No interface. Includes all versions of IE :( - // -------------------------------------------- - - this._awaitingPaste = true; - - var body = this._doc.body, - range = this.getSelection(), - startContainer = range.startContainer, - startOffset = range.startOffset, - endContainer = range.endContainer, - endOffset = range.endOffset; - - // We need to position the pasteArea in the visible portion of the screen - // to stop the browser auto-scrolling. - var pasteArea = this.createElement( 'DIV', { - contenteditable: 'true', - style: 'position:fixed; overflow:hidden; top:0; right:100%; width:1px; height:1px;' - }); - body.appendChild( pasteArea ); - range.selectNodeContents( pasteArea ); - this.setSelection( range ); - - // A setTimeout of 0 means this is added to the back of the - // single javascript thread, so it will be executed after the - // paste event. - setTimeout( function () { - try { - // IE sometimes fires the beforepaste event twice; make sure it is - // not run again before our after paste function is called. - self._awaitingPaste = false; - - // Get the pasted content and clean - var html = '', - next = pasteArea, - first, range; - - // #88: Chrome can apparently split the paste area if certain - // content is inserted; gather them all up. - while ( pasteArea = next ) { - next = pasteArea.nextSibling; - detach( pasteArea ); - // Safari and IE like putting extra divs around things. - first = pasteArea.firstChild; - if ( first && first === pasteArea.lastChild && - first.nodeName === 'DIV' ) { - pasteArea = first; - } - html += pasteArea.innerHTML; - } - - range = self.createRange( - startContainer, startOffset, endContainer, endOffset ); - self.setSelection( range ); - - if ( html ) { - self.insertHTML( html, true ); - } - } catch ( error ) { - self.didError( error ); - } - }, 0 ); -}; - -// On Windows you can drag an drop text. We can't handle this ourselves, because -// as far as I can see, there's no way to get the drop insertion point. So just -// save an undo state and hope for the best. -var onDrop = function ( event ) { - var types = event.dataTransfer.types; - var l = types.length; - var hasPlain = false; - var hasHTML = false; - while ( l-- ) { - switch ( types[l] ) { - case 'text/plain': - hasPlain = true; - break; - case 'text/html': - hasHTML = true; - break; - default: - return; - } - } - if ( hasHTML || hasPlain ) { - this.saveUndoState(); - } -}; - -function mergeObjects ( base, extras, mayOverride ) { - var prop, value; - if ( !base ) { - base = {}; - } - if ( extras ) { - for ( prop in extras ) { - if ( mayOverride || !( prop in base ) ) { - value = extras[ prop ]; - base[ prop ] = ( value && value.constructor === Object ) ? - mergeObjects( base[ prop ], value, mayOverride ) : - value; - } - } - } - return base; -} - -function Squire ( root, config ) { - if ( root.nodeType === DOCUMENT_NODE ) { - root = root.body; - } - var doc = root.ownerDocument; - var win = doc.defaultView; - var mutation; - - this._win = win; - this._doc = doc; - this._root = root; - - this._events = {}; - - this._isFocused = false; - this._lastSelection = null; - - this._hasZWS = false; - - this._lastAnchorNode = null; - this._lastFocusNode = null; - this._path = ''; - this._willUpdatePath = false; - - if ( 'onselectionchange' in doc ) { - this.addEventListener( 'selectionchange', this._updatePathOnEvent ); - } else { - this.addEventListener( 'keyup', this._updatePathOnEvent ); - this.addEventListener( 'mouseup', this._updatePathOnEvent ); - } - - this._undoIndex = -1; - this._undoStack = []; - this._undoStackLength = 0; - this._isInUndoState = false; - this._ignoreChange = false; - this._ignoreAllChanges = false; - - if ( canObserveMutations ) { - mutation = new MutationObserver( this._docWasChanged.bind( this ) ); - mutation.observe( root, { - childList: true, - attributes: true, - characterData: true, - subtree: true - }); - this._mutation = mutation; - } else { - this.addEventListener( 'keyup', this._keyUpDetectChange ); - } - - // On blur, restore focus except if the user taps or clicks to focus a - // specific point. Can't actually use click event because focus happens - // before click, so use mousedown/touchstart - this._restoreSelection = false; - this.addEventListener( 'blur', enableRestoreSelection ); - this.addEventListener( 'mousedown', disableRestoreSelection ); - this.addEventListener( 'touchstart', disableRestoreSelection ); - this.addEventListener( 'focus', restoreSelection ); - - // IE sometimes fires the beforepaste event twice; make sure it is not run - // again before our after paste function is called. - this._awaitingPaste = false; - this.addEventListener( 'cut', onCut ); - this.addEventListener( 'copy', onCopy ); - this.addEventListener( 'keydown', monitorShiftKey ); - this.addEventListener( 'keyup', monitorShiftKey ); - this.addEventListener( 'paste', onPaste ); - this.addEventListener( 'drop', onDrop ); - this.addEventListener( 'keydown', onKey ); - - // Add key handlers - this._keyHandlers = Object.create( keyHandlers ); - - // Override default properties - this.setConfig( config ); - - root.setAttribute( 'contenteditable', 'true' ); - // Grammarly breaks the editor, *sigh* - root.setAttribute( 'data-gramm', 'false' ); - - // Remove Firefox's built-in controls - try { - doc.execCommand( 'enableObjectResizing', false, 'false' ); - doc.execCommand( 'enableInlineTableEditing', false, 'false' ); - } catch ( error ) {} - - root.__squire__ = this; - - // Need to register instance before calling setHTML, so that the fixCursor - // function can lookup any default block tag options set. - this.setHTML( '' ); -} - -var proto = Squire.prototype; - -var sanitizeToDOMFragment = function ( html, isPaste, self ) { - var doc = self._doc; - var frag = html ? DOMPurify.sanitize( html, { - ALLOW_UNKNOWN_PROTOCOLS: true, - WHOLE_DOCUMENT: false, - RETURN_DOM: true, - RETURN_DOM_FRAGMENT: true, - FORCE_BODY: false - }) : null; - return frag ? doc.importNode( frag, true ) : doc.createDocumentFragment(); -}; - -proto.setConfig = function ( config ) { - config = mergeObjects({ - blockTag: 'DIV', - blockAttributes: null, - tagAttributes: { - blockquote: null, - ul: null, - ol: null, - li: null, - a: null - }, - classNames: { - colour: 'colour', - fontFamily: 'font', - fontSize: 'size', - highlight: 'highlight' - }, - leafNodeNames: leafNodeNames, - undo: { - documentSizeThreshold: -1, // -1 means no threshold - undoLimit: -1 // -1 means no limit - }, - isInsertedHTMLSanitized: true, - isSetHTMLSanitized: true, - sanitizeToDOMFragment: - typeof DOMPurify !== 'undefined' && DOMPurify.isSupported ? - sanitizeToDOMFragment : null, - willCutCopy: null, - addLinks: true - }, config, true ); - - // Users may specify block tag in lower case - config.blockTag = config.blockTag.toUpperCase(); - - this._config = config; - - return this; -}; - -proto.createElement = function ( tag, props, children ) { - return createElement( this._doc, tag, props, children ); -}; - -proto.createDefaultBlock = function ( children ) { - var config = this._config; - return fixCursor( - this.createElement( config.blockTag, config.blockAttributes, children ), - this._root - ); -}; - -proto.didError = function ( error ) { - console.log( error ); -}; - -proto.getDocument = function () { - return this._doc; -}; -proto.getRoot = function () { - return this._root; -}; - -proto.modifyDocument = function ( modificationCallback ) { - var mutation = this._mutation; - if ( mutation ) { - if ( mutation.takeRecords().length ) { - this._docWasChanged(); - } - mutation.disconnect(); - } - - this._ignoreAllChanges = true; - modificationCallback(); - this._ignoreAllChanges = false; - - if ( mutation ) { - mutation.observe( this._root, { - childList: true, - attributes: true, - characterData: true, - subtree: true - }); - this._ignoreChange = false; - } -}; - -// --- Events --- - -// Subscribing to these events won't automatically add a listener to the -// document node, since these events are fired in a custom manner by the -// editor code. -var customEvents = { - pathChange: 1, select: 1, input: 1, undoStateChange: 1 -}; - -proto.fireEvent = function ( type, event ) { - var handlers = this._events[ type ]; - var isFocused, l, obj; - // UI code, especially modal views, may be monitoring for focus events and - // immediately removing focus. In certain conditions, this can cause the - // focus event to fire after the blur event, which can cause an infinite - // loop. So we detect whether we're actually focused/blurred before firing. - if ( /^(?:focus|blur)/.test( type ) ) { - isFocused = this._root === this._doc.activeElement; - if ( type === 'focus' ) { - if ( !isFocused || this._isFocused ) { - return this; - } - this._isFocused = true; - } else { - if ( isFocused || !this._isFocused ) { - return this; - } - this._isFocused = false; - } - } - if ( handlers ) { - if ( !event ) { - event = {}; - } - if ( event.type !== type ) { - event.type = type; - } - // Clone handlers array, so any handlers added/removed do not affect it. - handlers = handlers.slice(); - l = handlers.length; - while ( l-- ) { - obj = handlers[l]; - try { - if ( obj.handleEvent ) { - obj.handleEvent( event ); - } else { - obj.call( this, event ); - } - } catch ( error ) { - error.details = 'Squire: fireEvent error. Event type: ' + type; - this.didError( error ); - } - } - } - return this; -}; - -proto.destroy = function () { - var events = this._events; - var type; - - for ( type in events ) { - this.removeEventListener( type ); - } - if ( this._mutation ) { - this._mutation.disconnect(); - } - delete this._root.__squire__; - - // Destroy undo stack - this._undoIndex = -1; - this._undoStack = []; - this._undoStackLength = 0; -}; - -proto.handleEvent = function ( event ) { - this.fireEvent( event.type, event ); -}; - -proto.addEventListener = function ( type, fn ) { - var handlers = this._events[ type ]; - var target = this._root; - if ( !fn ) { - this.didError({ - name: 'Squire: addEventListener with null or undefined fn', - message: 'Event type: ' + type - }); - return this; - } - if ( !handlers ) { - handlers = this._events[ type ] = []; - if ( !customEvents[ type ] ) { - if ( type === 'selectionchange' ) { - target = this._doc; - } - target.addEventListener( type, this, true ); - } - } - handlers.push( fn ); - return this; -}; - -proto.removeEventListener = function ( type, fn ) { - var handlers = this._events[ type ]; - var target = this._root; - var l; - if ( handlers ) { - if ( fn ) { - l = handlers.length; - while ( l-- ) { - if ( handlers[l] === fn ) { - handlers.splice( l, 1 ); - } - } - } else { - handlers.length = 0; - } - if ( !handlers.length ) { - delete this._events[ type ]; - if ( !customEvents[ type ] ) { - if ( type === 'selectionchange' ) { - target = this._doc; - } - target.removeEventListener( type, this, true ); - } - } - } - return this; -}; - -// --- Selection and Path --- - -proto.createRange = - function ( range, startOffset, endContainer, endOffset ) { - if ( range instanceof this._win.Range ) { - return range.cloneRange(); - } - var domRange = this._doc.createRange(); - domRange.setStart( range, startOffset ); - if ( endContainer ) { - domRange.setEnd( endContainer, endOffset ); - } else { - domRange.setEnd( range, startOffset ); - } - return domRange; -}; - -proto.getCursorPosition = function ( range ) { - if ( ( !range && !( range = this.getSelection() ) ) || - !range.getBoundingClientRect ) { - return null; - } - // Get the bounding rect - var rect = range.getBoundingClientRect(); - var node, parent; - if ( rect && !rect.top ) { - this._ignoreChange = true; - node = this._doc.createElement( 'SPAN' ); - node.textContent = ZWS; - insertNodeInRange( range, node ); - rect = node.getBoundingClientRect(); - parent = node.parentNode; - parent.removeChild( node ); - mergeInlines( parent, range ); - } - return rect; -}; - -proto._moveCursorTo = function ( toStart ) { - var root = this._root, - range = this.createRange( root, toStart ? 0 : root.childNodes.length ); - moveRangeBoundariesDownTree( range ); - this.setSelection( range ); - return this; -}; -proto.moveCursorToStart = function () { - return this._moveCursorTo( true ); -}; -proto.moveCursorToEnd = function () { - return this._moveCursorTo( false ); -}; - -var getWindowSelection = function ( self ) { - return self._win.getSelection() || null; -}; - -proto.setSelection = function ( range ) { - if ( range ) { - this._lastSelection = range; - // If we're setting selection, that automatically, and synchronously, // triggers a focus event. So just store the selection and mark it as - // needing restore on focus. - if ( !this._isFocused ) { - enableRestoreSelection.call( this ); - } else { - // iOS bug: if you don't focus the iframe before setting the - // selection, you can end up in a state where you type but the input - // doesn't get directed into the contenteditable area but is instead - // lost in a black hole. Very strange. - if ( isIOS ) { - this._win.focus(); - } - var sel = getWindowSelection( this ); - if ( sel && sel.setBaseAndExtent ) { - sel.setBaseAndExtent( - range.startContainer, - range.startOffset, - range.endContainer, - range.endOffset, - ); - } else if ( sel ) { - // This is just for IE11 - sel.removeAllRanges(); - sel.addRange( range ); - } - } - } - return this; -}; - -proto.getSelection = function () { - var sel = getWindowSelection( this ); - var root = this._root; - var selection, startContainer, endContainer, node; - // If not focused, always rely on cached selection; another function may - // have set it but the DOM is not modified until focus again - if ( this._isFocused && sel && sel.rangeCount ) { - selection = sel.getRangeAt( 0 ).cloneRange(); - startContainer = selection.startContainer; - endContainer = selection.endContainer; - // FF can return the selection as being inside an . WTF? - if ( startContainer && isLeaf( startContainer ) ) { - selection.setStartBefore( startContainer ); - } - if ( endContainer && isLeaf( endContainer ) ) { - selection.setEndBefore( endContainer ); - } - } - if ( selection && - isOrContains( root, selection.commonAncestorContainer ) ) { - this._lastSelection = selection; - } else { - selection = this._lastSelection; - node = selection.commonAncestorContainer; - // Check the editor is in the live document; if not, the range has - // probably been rewritten by the browser and is bogus - if ( !isOrContains( node.ownerDocument, node ) ) { - selection = null; - } - } - if ( !selection ) { - selection = this.createRange( root.firstChild, 0 ); - } - return selection; -}; - -function enableRestoreSelection () { - this._restoreSelection = true; -} -function disableRestoreSelection () { - this._restoreSelection = false; -} -function restoreSelection () { - if ( this._restoreSelection ) { - this.setSelection( this._lastSelection ); - } -} - -proto.getSelectedText = function () { - var range = this.getSelection(); - if ( !range || range.collapsed ) { - return ''; - } - var walker = new TreeWalker( - range.commonAncestorContainer, - SHOW_TEXT|SHOW_ELEMENT, - function ( node ) { - return isNodeContainedInRange( range, node, true ); - } - ); - var startContainer = range.startContainer; - var endContainer = range.endContainer; - var node = walker.currentNode = startContainer; - var textContent = ''; - var addedTextInBlock = false; - var value; - - if ( !walker.filter( node ) ) { - node = walker.nextNode(); - } - - while ( node ) { - if ( node.nodeType === TEXT_NODE ) { - value = node.data; - if ( value && ( /\S/.test( value ) ) ) { - if ( node === endContainer ) { - value = value.slice( 0, range.endOffset ); - } - if ( node === startContainer ) { - value = value.slice( range.startOffset ); - } - textContent += value; - addedTextInBlock = true; - } - } else if ( node.nodeName === 'BR' || - addedTextInBlock && !isInline( node ) ) { - textContent += '\n'; - addedTextInBlock = false; - } - node = walker.nextNode(); - } - - return textContent; -}; - -proto.getPath = function () { - return this._path; -}; - -// --- Workaround for browsers that can't focus empty text nodes --- - -// WebKit bug: https://bugs.webkit.org/show_bug.cgi?id=15256 - -// Walk down the tree starting at the root and remove any ZWS. If the node only -// contained ZWS space then remove it too. We may want to keep one ZWS node at -// the bottom of the tree so the block can be selected. Define that node as the -// keepNode. -var removeZWS = function ( root, keepNode ) { - var walker = new TreeWalker( root, SHOW_TEXT ); - var parent, node, index; - while ( node = walker.nextNode() ) { - while ( ( index = node.data.indexOf( ZWS ) ) > -1 && - ( !keepNode || node.parentNode !== keepNode ) ) { - if ( node.length === 1 ) { - do { - parent = node.parentNode; - parent.removeChild( node ); - node = parent; - walker.currentNode = parent; - } while ( isInline( node ) && !getLength( node ) ); - break; - } else { - node.deleteData( index, 1 ); - } - } - } -}; - -proto._didAddZWS = function () { - this._hasZWS = true; -}; -proto._removeZWS = function () { - if ( !this._hasZWS ) { - return; - } - removeZWS( this._root ); - this._hasZWS = false; -}; - -// --- Path change events --- - -proto._updatePath = function ( range, force ) { - if ( !range ) { - return; - } - var anchor = range.startContainer, - focus = range.endContainer, - newPath; - if ( force || anchor !== this._lastAnchorNode || - focus !== this._lastFocusNode ) { - this._lastAnchorNode = anchor; - this._lastFocusNode = focus; - newPath = ( anchor && focus ) ? ( anchor === focus ) ? - getPath( focus, this._root, this._config ) : '(selection)' : ''; - if ( this._path !== newPath ) { - this._path = newPath; - this.fireEvent( 'pathChange', { path: newPath } ); - } - } - this.fireEvent( range.collapsed ? 'cursor' : 'select', { - range: range - }); -}; - -// selectionchange is fired synchronously in IE when removing current selection -// and when setting new selection; keyup/mouseup may have processing we want -// to do first. Either way, send to next event loop. -proto._updatePathOnEvent = function () { - var self = this; - if ( self._isFocused && !self._willUpdatePath ) { - self._willUpdatePath = true; - setTimeout( function () { - self._willUpdatePath = false; - self._updatePath( self.getSelection() ); - }, 0 ); - } -}; - -// --- Focus --- - -proto.focus = function () { - this._root.focus({ preventScroll: true }); - - if ( isIE ) { - this.fireEvent( 'focus' ); - } - - return this; -}; - -proto.blur = function () { - this._root.blur(); - - if ( isIE ) { - this.fireEvent( 'blur' ); - } - - return this; -}; - -// --- Bookmarking --- - -var startSelectionId = 'squire-selection-start'; -var endSelectionId = 'squire-selection-end'; - -proto._saveRangeToBookmark = function ( range ) { - var startNode = this.createElement( 'INPUT', { - id: startSelectionId, - type: 'hidden' - }), - endNode = this.createElement( 'INPUT', { - id: endSelectionId, - type: 'hidden' - }), - temp; - - insertNodeInRange( range, startNode ); - range.collapse( false ); - insertNodeInRange( range, endNode ); - - // In a collapsed range, the start is sometimes inserted after the end! - if ( startNode.compareDocumentPosition( endNode ) & - DOCUMENT_POSITION_PRECEDING ) { - startNode.id = endSelectionId; - endNode.id = startSelectionId; - temp = startNode; - startNode = endNode; - endNode = temp; - } - - range.setStartAfter( startNode ); - range.setEndBefore( endNode ); -}; - -proto._getRangeAndRemoveBookmark = function ( range ) { - var root = this._root, - start = root.querySelector( '#' + startSelectionId ), - end = root.querySelector( '#' + endSelectionId ); - - if ( start && end ) { - var startContainer = start.parentNode, - endContainer = end.parentNode, - startOffset = indexOf.call( startContainer.childNodes, start ), - endOffset = indexOf.call( endContainer.childNodes, end ); - - if ( startContainer === endContainer ) { - endOffset -= 1; - } - - detach( start ); - detach( end ); - - if ( !range ) { - range = this._doc.createRange(); - } - range.setStart( startContainer, startOffset ); - range.setEnd( endContainer, endOffset ); - - // Merge any text nodes we split - mergeInlines( startContainer, range ); - if ( startContainer !== endContainer ) { - mergeInlines( endContainer, range ); - } - - // If we didn't split a text node, we should move into any adjacent - // text node to current selection point - if ( range.collapsed ) { - startContainer = range.startContainer; - if ( startContainer.nodeType === TEXT_NODE ) { - endContainer = startContainer.childNodes[ range.startOffset ]; - if ( !endContainer || endContainer.nodeType !== TEXT_NODE ) { - endContainer = - startContainer.childNodes[ range.startOffset - 1 ]; - } - if ( endContainer && endContainer.nodeType === TEXT_NODE ) { - range.setStart( endContainer, 0 ); - range.collapse( true ); - } - } - } - } - return range || null; -}; - -// --- Undo --- - -proto._keyUpDetectChange = function ( event ) { - var code = event.keyCode; - // Presume document was changed if: - // 1. A modifier key (other than shift) wasn't held down - // 2. The key pressed is not in range 16<=x<=20 (control keys) - // 3. The key pressed is not in range 33<=x<=45 (navigation keys) - if ( !event.ctrlKey && !event.metaKey && !event.altKey && - ( code < 16 || code > 20 ) && - ( code < 33 || code > 45 ) ) { - this._docWasChanged(); - } -}; - -proto._docWasChanged = function () { - if ( canWeakMap ) { - nodeCategoryCache = new WeakMap(); - } - if ( this._ignoreAllChanges ) { - return; - } - - if ( canObserveMutations && this._ignoreChange ) { - this._ignoreChange = false; - return; - } - if ( this._isInUndoState ) { - this._isInUndoState = false; - this.fireEvent( 'undoStateChange', { - canUndo: true, - canRedo: false - }); - } - this.fireEvent( 'input' ); -}; - -// Leaves bookmark -proto._recordUndoState = function ( range, replace ) { - // Don't record if we're already in an undo state - if ( !this._isInUndoState|| replace ) { - // Advance pointer to new position - var undoIndex = this._undoIndex; - var undoStack = this._undoStack; - var undoConfig = this._config.undo; - var undoThreshold = undoConfig.documentSizeThreshold; - var undoLimit = undoConfig.undoLimit; - var html; - - if ( !replace ) { - undoIndex += 1; - } - - // Truncate stack if longer (i.e. if has been previously undone) - if ( undoIndex < this._undoStackLength ) { - undoStack.length = this._undoStackLength = undoIndex; - } - - // Get data - if ( range ) { - this._saveRangeToBookmark( range ); - } - html = this._getHTML(); - - // If this document is above the configured size threshold, - // limit the number of saved undo states. - // Threshold is in bytes, JS uses 2 bytes per character - if ( undoThreshold > -1 && html.length * 2 > undoThreshold ) { - if ( undoLimit > -1 && undoIndex > undoLimit ) { - undoStack.splice( 0, undoIndex - undoLimit ); - undoIndex = undoLimit; - this._undoStackLength = undoLimit; - } - } - - // Save data - undoStack[ undoIndex ] = html; - this._undoIndex = undoIndex; - this._undoStackLength += 1; - this._isInUndoState = true; - } -}; - -proto.saveUndoState = function ( range ) { - if ( range === undefined ) { - range = this.getSelection(); - } - this._recordUndoState( range, this._isInUndoState ); - this._getRangeAndRemoveBookmark( range ); - - return this; -}; - -proto.undo = function () { - // Sanity check: must not be at beginning of the history stack - if ( this._undoIndex !== 0 || !this._isInUndoState ) { - // Make sure any changes since last checkpoint are saved. - this._recordUndoState( this.getSelection(), false ); - - this._undoIndex -= 1; - this._setHTML( this._undoStack[ this._undoIndex ] ); - var range = this._getRangeAndRemoveBookmark(); - if ( range ) { - this.setSelection( range ); - } - this._isInUndoState = true; - this.fireEvent( 'undoStateChange', { - canUndo: this._undoIndex !== 0, - canRedo: true - }); - this.fireEvent( 'input' ); - } - return this; -}; - -proto.redo = function () { - // Sanity check: must not be at end of stack and must be in an undo - // state. - var undoIndex = this._undoIndex, - undoStackLength = this._undoStackLength; - if ( undoIndex + 1 < undoStackLength && this._isInUndoState ) { - this._undoIndex += 1; - this._setHTML( this._undoStack[ this._undoIndex ] ); - var range = this._getRangeAndRemoveBookmark(); - if ( range ) { - this.setSelection( range ); - } - this.fireEvent( 'undoStateChange', { - canUndo: true, - canRedo: undoIndex + 2 < undoStackLength - }); - this.fireEvent( 'input' ); - } - return this; -}; - -// --- Inline formatting --- - -// Looks for matching tag and attributes, so won't work -// if instead of etc. -proto.hasFormat = function ( tag, attributes, range ) { - // 1. Normalise the arguments and get selection - tag = tag.toUpperCase(); - if ( !attributes ) { attributes = {}; } - if ( !range && !( range = this.getSelection() ) ) { - return false; - } - - // Sanitize range to prevent weird IE artifacts - if ( !range.collapsed && - range.startContainer.nodeType === TEXT_NODE && - range.startOffset === range.startContainer.length && - range.startContainer.nextSibling ) { - range.setStartBefore( range.startContainer.nextSibling ); - } - if ( !range.collapsed && - range.endContainer.nodeType === TEXT_NODE && - range.endOffset === 0 && - range.endContainer.previousSibling ) { - range.setEndAfter( range.endContainer.previousSibling ); - } - - // If the common ancestor is inside the tag we require, we definitely - // have the format. - var root = this._root; - var common = range.commonAncestorContainer; - var walker, node; - if ( getNearest( common, root, tag, attributes ) ) { - return true; - } - - // If common ancestor is a text node and doesn't have the format, we - // definitely don't have it. - if ( common.nodeType === TEXT_NODE ) { - return false; - } - - // Otherwise, check each text node at least partially contained within - // the selection and make sure all of them have the format we want. - walker = new TreeWalker( common, SHOW_TEXT, function ( node ) { - return isNodeContainedInRange( range, node, true ); - }); - - var seenNode = false; - while ( node = walker.nextNode() ) { - if ( !getNearest( node, root, tag, attributes ) ) { - return false; - } - seenNode = true; - } - - return seenNode; -}; - -// Extracts the font-family and font-size (if any) of the element -// holding the cursor. If there's a selection, returns an empty object. -proto.getFontInfo = function ( range ) { - var fontInfo = { - color: undefined, - backgroundColor: undefined, - family: undefined, - size: undefined - }; - var seenAttributes = 0; - var element, style, attr; - - if ( !range && !( range = this.getSelection() ) ) { - return fontInfo; - } - - element = range.commonAncestorContainer; - if ( range.collapsed || element.nodeType === TEXT_NODE ) { - if ( element.nodeType === TEXT_NODE ) { - element = element.parentNode; - } - while ( seenAttributes < 4 && element ) { - if ( style = element.style ) { - if ( !fontInfo.color && ( attr = style.color ) ) { - fontInfo.color = attr; - seenAttributes += 1; - } - if ( !fontInfo.backgroundColor && - ( attr = style.backgroundColor ) ) { - fontInfo.backgroundColor = attr; - seenAttributes += 1; - } - if ( !fontInfo.family && ( attr = style.fontFamily ) ) { - fontInfo.family = attr; - seenAttributes += 1; - } - if ( !fontInfo.size && ( attr = style.fontSize ) ) { - fontInfo.size = attr; - seenAttributes += 1; - } - } - element = element.parentNode; - } - } - return fontInfo; -}; - -proto._addFormat = function ( tag, attributes, range ) { - // If the range is collapsed we simply insert the node by wrapping - // it round the range and focus it. - var root = this._root; - var el, walker, startContainer, endContainer, startOffset, endOffset, - node, needsFormat, block; - - if ( range.collapsed ) { - el = fixCursor( this.createElement( tag, attributes ), root ); - insertNodeInRange( range, el ); - range.setStart( el.firstChild, el.firstChild.length ); - range.collapse( true ); - - // Clean up any previous formats that may have been set on this block - // that are unused. - block = el; - while ( isInline( block ) ) { - block = block.parentNode; - } - removeZWS( block, el ); - } - // Otherwise we find all the textnodes in the range (splitting - // partially selected nodes) and if they're not already formatted - // correctly we wrap them in the appropriate tag. - else { - // Create an iterator to walk over all the text nodes under this - // ancestor which are in the range and not already formatted - // correctly. - // - // In Blink/WebKit, empty blocks may have no text nodes, just a
. - // Therefore we wrap this in the tag as well, as this will then cause it - // to apply when the user types something in the block, which is - // presumably what was intended. - // - // IMG tags are included because we may want to create a link around - // them, and adding other styles is harmless. - walker = new TreeWalker( - range.commonAncestorContainer, - SHOW_TEXT|SHOW_ELEMENT, - function ( node ) { - return ( node.nodeType === TEXT_NODE || - node.nodeName === 'BR' || - node.nodeName === 'IMG' - ) && isNodeContainedInRange( range, node, true ); - } - ); - - // Start at the beginning node of the range and iterate through - // all the nodes in the range that need formatting. - startContainer = range.startContainer; - startOffset = range.startOffset; - endContainer = range.endContainer; - endOffset = range.endOffset; - - // Make sure we start with a valid node. - walker.currentNode = startContainer; - if ( !walker.filter( startContainer ) ) { - startContainer = walker.nextNode(); - startOffset = 0; - } - - // If there are no interesting nodes in the selection, abort - if ( !startContainer ) { - return range; - } - - do { - node = walker.currentNode; - needsFormat = !getNearest( node, root, tag, attributes ); - if ( needsFormat ) { - //
can never be a container node, so must have a text node - // if node == (end|start)Container - if ( node === endContainer && node.length > endOffset ) { - node.splitText( endOffset ); - } - if ( node === startContainer && startOffset ) { - node = node.splitText( startOffset ); - if ( endContainer === startContainer ) { - endContainer = node; - endOffset -= startOffset; - } - startContainer = node; - startOffset = 0; - } - el = this.createElement( tag, attributes ); - replaceWith( node, el ); - el.appendChild( node ); - } - } while ( walker.nextNode() ); - - // If we don't finish inside a text node, offset may have changed. - if ( endContainer.nodeType !== TEXT_NODE ) { - if ( node.nodeType === TEXT_NODE ) { - endContainer = node; - endOffset = node.length; - } else { - // If
, we must have just wrapped it, so it must have only - // one child - endContainer = node.parentNode; - endOffset = 1; - } - } - - // Now set the selection to as it was before - range = this.createRange( - startContainer, startOffset, endContainer, endOffset ); - } - return range; -}; - -proto._removeFormat = function ( tag, attributes, range, partial ) { - // Add bookmark - this._saveRangeToBookmark( range ); - - // We need a node in the selection to break the surrounding - // formatted text. - var doc = this._doc, - fixer; - if ( range.collapsed ) { - if ( cantFocusEmptyTextNodes ) { - fixer = doc.createTextNode( ZWS ); - this._didAddZWS(); - } else { - fixer = doc.createTextNode( '' ); - } - insertNodeInRange( range, fixer ); - } - - // Find block-level ancestor of selection - var root = range.commonAncestorContainer; - while ( isInline( root ) ) { - root = root.parentNode; - } - - // Find text nodes inside formatTags that are not in selection and - // add an extra tag with the same formatting. - var startContainer = range.startContainer, - startOffset = range.startOffset, - endContainer = range.endContainer, - endOffset = range.endOffset, - toWrap = [], - examineNode = function ( node, exemplar ) { - // If the node is completely contained by the range then - // we're going to remove all formatting so ignore it. - if ( isNodeContainedInRange( range, node, false ) ) { - return; - } - - var isText = ( node.nodeType === TEXT_NODE ), - child, next; - - // If not at least partially contained, wrap entire contents - // in a clone of the tag we're removing and we're done. - if ( !isNodeContainedInRange( range, node, true ) ) { - // Ignore bookmarks and empty text nodes - if ( node.nodeName !== 'INPUT' && - ( !isText || node.data ) ) { - toWrap.push([ exemplar, node ]); - } - return; - } - - // Split any partially selected text nodes. - if ( isText ) { - if ( node === endContainer && endOffset !== node.length ) { - toWrap.push([ exemplar, node.splitText( endOffset ) ]); - } - if ( node === startContainer && startOffset ) { - node.splitText( startOffset ); - toWrap.push([ exemplar, node ]); - } - } - // If not a text node, recurse onto all children. - // Beware, the tree may be rewritten with each call - // to examineNode, hence find the next sibling first. - else { - for ( child = node.firstChild; child; child = next ) { - next = child.nextSibling; - examineNode( child, exemplar ); - } - } - }, - formatTags = Array.prototype.filter.call( - root.getElementsByTagName( tag ), function ( el ) { - return isNodeContainedInRange( range, el, true ) && - hasTagAttributes( el, tag, attributes ); - } - ); - - if ( !partial ) { - formatTags.forEach( function ( node ) { - examineNode( node, node ); - }); - } - - // Now wrap unselected nodes in the tag - toWrap.forEach( function ( item ) { - // [ exemplar, node ] tuple - var el = item[0].cloneNode( false ), - node = item[1]; - replaceWith( node, el ); - el.appendChild( node ); - }); - // and remove old formatting tags. - formatTags.forEach( function ( el ) { - replaceWith( el, empty( el ) ); - }); - - // Merge adjacent inlines: - this._getRangeAndRemoveBookmark( range ); - if ( fixer ) { - range.collapse( false ); - } - mergeInlines( root, range ); - - return range; -}; - -proto.changeFormat = function ( add, remove, range, partial ) { - // Normalise the arguments and get selection - if ( !range && !( range = this.getSelection() ) ) { - return this; - } - - // Save undo checkpoint - this.saveUndoState( range ); - - if ( remove ) { - range = this._removeFormat( remove.tag.toUpperCase(), - remove.attributes || {}, range, partial ); - } - if ( add ) { - range = this._addFormat( add.tag.toUpperCase(), - add.attributes || {}, range ); - } - - this.setSelection( range ); - this._updatePath( range, true ); - - // We're not still in an undo state - if ( !canObserveMutations ) { - this._docWasChanged(); - } - - return this; -}; - -// --- Block formatting --- - -var tagAfterSplit = { - DT: 'DD', - DD: 'DT', - LI: 'LI', - PRE: 'PRE' -}; - -var splitBlock = function ( self, block, node, offset ) { - var splitTag = tagAfterSplit[ block.nodeName ], - splitProperties = null, - nodeAfterSplit = split( node, offset, block.parentNode, self._root ), - config = self._config; - - if ( !splitTag ) { - splitTag = config.blockTag; - splitProperties = config.blockAttributes; - } - - // Make sure the new node is the correct type. - if ( !hasTagAttributes( nodeAfterSplit, splitTag, splitProperties ) ) { - block = createElement( nodeAfterSplit.ownerDocument, - splitTag, splitProperties ); - if ( nodeAfterSplit.dir ) { - block.dir = nodeAfterSplit.dir; - } - replaceWith( nodeAfterSplit, block ); - block.appendChild( empty( nodeAfterSplit ) ); - nodeAfterSplit = block; - } - return nodeAfterSplit; -}; - -proto.forEachBlock = function ( fn, mutates, range ) { - if ( !range && !( range = this.getSelection() ) ) { - return this; - } - - // Save undo checkpoint - if ( mutates ) { - this.saveUndoState( range ); - } - - var root = this._root; - var start = getStartBlockOfRange( range, root ); - var end = getEndBlockOfRange( range, root ); - if ( start && end ) { - do { - if ( fn( start ) || start === end ) { break; } - } while ( start = getNextBlock( start, root ) ); - } - - if ( mutates ) { - this.setSelection( range ); - - // Path may have changed - this._updatePath( range, true ); - - // We're not still in an undo state - if ( !canObserveMutations ) { - this._docWasChanged(); - } - } - return this; -}; - -proto.modifyBlocks = function ( modify, range ) { - if ( !range && !( range = this.getSelection() ) ) { - return this; - } - - // 1. Save undo checkpoint and bookmark selection - this._recordUndoState( range, this._isInUndoState ); - - var root = this._root; - var frag; - - // 2. Expand range to block boundaries - expandRangeToBlockBoundaries( range, root ); - - // 3. Remove range. - moveRangeBoundariesUpTree( range, root, root, root ); - frag = extractContentsOfRange( range, root, root ); - - // 4. Modify tree of fragment and reinsert. - insertNodeInRange( range, modify.call( this, frag ) ); - - // 5. Merge containers at edges - if ( range.endOffset < range.endContainer.childNodes.length ) { - mergeContainers( range.endContainer.childNodes[ range.endOffset ], root ); - } - mergeContainers( range.startContainer.childNodes[ range.startOffset ], root ); - - // 6. Restore selection - this._getRangeAndRemoveBookmark( range ); - this.setSelection( range ); - this._updatePath( range, true ); - - // 7. We're not still in an undo state - if ( !canObserveMutations ) { - this._docWasChanged(); - } - - return this; -}; - -var increaseBlockQuoteLevel = function ( frag ) { - return this.createElement( 'BLOCKQUOTE', - this._config.tagAttributes.blockquote, [ - frag - ]); -}; - -var decreaseBlockQuoteLevel = function ( frag ) { - var blockquotes = frag.querySelectorAll( 'blockquote' ); - Array.prototype.filter.call( blockquotes, function ( el ) { - return !getNearest( el.parentNode, frag, 'BLOCKQUOTE' ); - }).forEach( function ( el ) { - replaceWith( el, empty( el ) ); - }); - return frag; -}; - -var removeBlockQuote = function (/* frag */) { - return this.createDefaultBlock([ - this.createElement( 'INPUT', { - id: startSelectionId, - type: 'hidden' - }), - this.createElement( 'INPUT', { - id: endSelectionId, - type: 'hidden' - }) - ]); -}; - -var makeList = function ( self, frag, type ) { - var walker = getBlockWalker( frag, self._root ), - node, tag, prev, newLi, - tagAttributes = self._config.tagAttributes, - listAttrs = tagAttributes[ type.toLowerCase() ], - listItemAttrs = tagAttributes.li; - - while ( node = walker.nextNode() ) { - if ( node.parentNode.nodeName === 'LI' ) { - node = node.parentNode; - walker.currentNode = node.lastChild; - } - if ( node.nodeName !== 'LI' ) { - newLi = self.createElement( 'LI', listItemAttrs ); - if ( node.dir ) { - newLi.dir = node.dir; - } - - // Have we replaced the previous block with a new