0
Fork 0
mirror of https://github.com/fastmail/Squire.git synced 2024-12-21 23:03:11 -05:00

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 <woods@fastmailteam.com>
This commit is contained in:
Neil Jenkins 2023-01-23 11:35:12 +11:00
parent c2df8eba45
commit fe0dfdf6c4
65 changed files with 30477 additions and 10837 deletions

67
.eslintrc.cjs Normal file
View file

@ -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',
},
};

1
.gitignore vendored
View file

@ -1,2 +1 @@
node_modules node_modules
bower_components

5
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,5 @@
test:
stage: test
script: npm ci --ignore-scripts --force && npm run test
only:
- merge_requests

View file

@ -1,6 +0,0 @@
{
"disallowSpacesInsideParentheses": false,
"disallowSpacesInFunctionDeclaration": {
"beforeOpeningRoundBrace": false
}
}

View file

@ -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
}

View file

@ -1,9 +1,12 @@
Makefile
.jscsrc
.jshintrc
.gitignore
.editorconfig
bower.json
source/ source/
test/ test/
Makefile
.editorconfig
.eslintrc.cjs
.gitignore
.gitlab-ci.yml
babel.config.cjs
Demo.html Demo.html
prettier.config.cjs
rollup.config.js
tsconfig.json

56
CHANGELOG.md Normal file
View file

@ -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.

View file

@ -3,7 +3,7 @@
<head> <head>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>HTML Editor Test</title> <title>Squire Editor Demo</title>
<style type="text/css" media="screen"> <style type="text/css" media="screen">
body { body {
position: relative; position: relative;
@ -72,9 +72,9 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad
</style> </style>
</head> </head>
<body> <body>
<h1>HTML Editor Test</h1> <h1>Squire Editor Demo</h1>
<header> <header>
<p>This is a really simple demo, with the most trivial of UI integrations</p> <p>Squire is a rich text editor primarily built for email apps. Its 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. <a href="https://github.com/neilj/squire">Learn more and see the source on GitHub</a>.</p>
<p> <p>
<span id="bold">Bold</span> <span id="bold">Bold</span>
<span id="removeBold">Unbold</span> <span id="removeBold">Unbold</span>
@ -90,12 +90,11 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad
<span id="setFontFace" class="prompt">Font face</span> <span id="setFontFace" class="prompt">Font face</span>
</p> </p>
<p> <p>
<span id="setTextColour" class="prompt">Text colour</span> <span id="setTextColor" class="prompt">Text color</span>
<span id="setHighlightColour" class="prompt">Text highlight</span> <span id="setHighlightColor" class="prompt">Text highlight</span>
<span id="makeLink" class="prompt">Link</span> <span id="makeLink" class="prompt">Link</span>
</p> </p>
<p> <p>
<span id="makeHeader">Make Header</span>
<span id="increaseQuoteLevel">Quote</span> <span id="increaseQuoteLevel">Quote</span>
<span id="decreaseQuoteLevel">Dequote</span> <span id="decreaseQuoteLevel">Dequote</span>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
@ -114,12 +113,13 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad
<span id="redo">Redo</span> <span id="redo">Redo</span>
</p> </p>
</header> </header>
<script type="text/javascript" src="build/squire-raw.js"></script>
<div id="editor"></div> <div id="editor"></div>
<script type="text/javascript" charset="utf-8"> <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.3/purify.min.js" integrity="sha512-3dcbndbDLLWfKWevQu8C/1qVRcxx6h+eRDSnn3/pcBZHISRJgsj3u71U/Ad6nQVHrEi05hOSr6Hnb/p0pWc94w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script type="text/javascript" src="dist/squire-raw.js"></script>
<script type="module" charset="utf-8">
var div = document.getElementById( 'editor' ); var div = document.getElementById( 'editor' );
var editor = new Squire( div, { var editor = new Squire( div, {
blockTag: 'p', blockTag: 'div',
blockAttributes: {'class': 'paragraph'}, blockAttributes: {'class': 'paragraph'},
tagAttributes: { tagAttributes: {
ul: {'class': 'UL'}, ul: {'class': 'UL'},
@ -134,18 +134,6 @@ white-space: pre-wrap;word-wrap: break-word;overflow-wrap: break-word;border-rad
}, },
} }
}); });
Squire.prototype.makeHeader = function() {
return this.modifyBlocks( function( frag ) {
var output = this._doc.createDocumentFragment();
var block = frag;
while ( block = Squire.getNextBlock( block ) ) {
output.appendChild(
this.createElement( 'h2', [ Squire.empty( block ) ] )
);
}
return output;
});
};
document.addEventListener( 'click', function ( e ) { document.addEventListener( 'click', function ( e ) {
var id = e.target.id, var id = e.target.id,

View file

@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright © 20112014 by Neil Jenkins Copyright © 20112023 by Neil Jenkins
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to of this software and associated documentation files (the "Software"), to

View file

@ -1,22 +0,0 @@
.PHONY: all build clean
all: install build
install:
npm install
clean:
rm -rf build
build: build/squire.js build/document.html
build/squire-raw.js: source/intro.js source/Constants.js source/TreeWalker.js source/Node.js source/Range.js source/KeyHandlers.js source/Clean.js source/Clipboard.js source/Editor.js source/exports.js source/outro.js
mkdir -p $(@D)
cat $^ | grep -v '^\/\*jshint' >$@
build/squire.js: build/squire-raw.js
./node_modules/uglify-js/bin/uglifyjs $^ -c -m -o $@
build/document.html: source/document.html
mkdir -p $(@D)
cp $^ $@

185
README.md
View file

@ -1,81 +1,64 @@
Squire # Squire
======
Squire is an HTML5 rich text editor, which provides powerful cross-browser normalisation in a flexible lightweight package (only 16.5KB of JS after minification and gzip, with no dependencies!). Squire is an HTML5 rich text editor, which provides powerful cross-browser normalisation in a flexible lightweight package (only 16KB of JS after minification and gzip, with no dependencies!).
It was designed to handle email composition for the [Fastmail](https://www.fastmail.com) web app. The most important consequence of this (and where Squire differs from most other modern rich text editors) is that it must handle arbitrary HTML, because it may be used to forward or quote emails from third-parties and must be able to preserve their HTML without breaking the formatting. This means that it can't use a more structured (but limited) internal data model (as most other modern HTML editors do) and the HTML remains the source-of-truth. The other consequence is excellent handling of multiple levels of blockquotes. It was designed to handle email composition for the [Fastmail](https://www.fastmail.com) web app. The most important consequence of this (and where Squire differs from most other modern rich text editors) is that it must handle arbitrary HTML, because it may be used to forward or quote emails from third-parties and must be able to preserve their HTML without breaking the formatting. This means that it can't use a more structured (but limited) internal data model (as most other modern HTML editors do) and the HTML remains the source-of-truth. The other consequence is excellent handling of multiple levels of blockquotes.
Squire was designed to be integrated with your own UI framework, and so does not provide its own UI toolbar, widgets or overlays. Instead, you get a component you can insert in place of a `<textarea>` and manipulate programatically, allowing you to integrate seamlessly with the rest of your application and lose the bloat of having two UI toolkits loaded. Squire is designed to be integrated with your own UI framework, and so does not provide its own UI toolbar, widgets or overlays. Instead, you get a component you can insert in place of a `<textarea>` and manipulate programatically, allowing you to integrate seamlessly with the rest of your application and lose the bloat of having two UI toolkits loaded.
Squire supports all reasonably recent, and even moderately old, browsers (even IE11, although this is not tested much these days). Squire supports all reasonably recent browsers. It no longer supports any version of IE.
In addition to its use at [Fastmail](https://www.fastmail.com), it is also currently used in production at [ProtonMail](https://protonmail.com/), [SnappyMail](https://github.com/the-djmaze/snappymail), [StartMail](https://startmail.com/), [Tutanota](https://tutanota.com), [Zoho Mail](https://www.zoho.com/mail/), [Superhuman](https://superhuman.com/) and [Teamwork Desk](https://www.teamwork.com/desk/), as well as other non-mail apps including [Google Earth](https://www.google.com/earth/) (drop me a line if you're using Squire elsewhere, I'm always interested to hear about it!). In addition to its use at [Fastmail](https://www.fastmail.com), it is also currently used in production at [ProtonMail](https://protonmail.com/), [SnappyMail](https://github.com/the-djmaze/snappymail), [StartMail](https://startmail.com/), [Tutanota](https://tutanota.com), [Zoho Mail](https://www.zoho.com/mail/), [Superhuman](https://superhuman.com/) and [Teamwork Desk](https://www.teamwork.com/desk/), as well as other non-mail apps including [Google Earth](https://www.google.com/earth/) (drop me a line if you're using Squire elsewhere, I'm always interested to hear about it!).
An example UI integration can be tried at http://neilj.github.io/Squire/. Please note though, this is an out-of-date version of Squire and a slightly buggy implementation written by an intern many years ago. For a demo of the latest version with a production-level UI integration, [sign up for a free Fastmail trial](https://www.fastmail.com/signup/) :). There's also a very bare-bones integration in the repo; just clone it and open `Demo.html`. If you are reporting a bug, please report the steps to reproduce using `Demo.html`, to make sure it's not a bug in your integration. For a demo of the latest version with a production-level UI integration, [sign up for a free Fastmail trial](https://www.fastmail.com/signup/) :). There's also a very bare-bones integration in the repo; just clone it and open `Demo.html`. If you are reporting a bug, please report the steps to reproduce using `Demo.html`, to make sure it's not a bug in your integration.
Installation and usage ## Installation and usage
----------------------
1. Copy the contents of the `build/` directory onto your server. 1. Add Squire to your project: `npm install squire-rte`
2. Edit the `<style>` block in document.html to add the default styles you 2. In your code, `import Squire from 'squire-rte';`
would like the editor to use (or link to an external stylesheet). 3. Create your editor by calling `editor = new Squire(node);`.
3. In your application, instead of a `<textarea>`, use an
`<iframe src="path/to/document.html">`.
4. In your JS, attach an event listener to the [`load` event](https://developer.mozilla.org/en-US/docs/Web/Events/load) of the iframe. When
this fires you can grab a reference to the editor object through
`iframe.contentWindow.editor`.
5. Use the API below with the `editor` object to set and get data and integrate
with your application or framework.
### Using Squire without an iframe. ### Invoke with script tag
Squire can also be used without an iframe for the document. To use it this way: Squire can also be used in a script tag:
1. Add a `<script>` tag to load in `build/squire.js` (or `squire-raw.js` for the debuggable unminified version). 1. Add a `<script>` tag to load in `dist/squire.js` (or `squire-raw.js` for the debuggable unminified version):
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.
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. ```
<script type="text/javascript" src="dist/squire.js"></script>
```
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 ### 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` ## Advanced usage
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
--------------
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. 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 `<html>` 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. 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. 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 ### Setting the default block style
By default, the editor will use a `<div>` for blank lines, as most users have been conditioned by Microsoft Word to expect <kbd>Enter</kbd> to act like pressing <kbd>return</kbd> on a typewriter. If you would like to use `<p>` 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 `<div>` for blank lines, as most users have been conditioned by Microsoft Word to expect <kbd>Enter</kbd> to act like pressing <kbd>return</kbd> on a typewriter. If you would like to use `<p>` 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: pass a set of attributes to apply to each default block:
var editor = new Squire( document, { var editor = new Squire(document, {
blockTag: 'P', blockTag: 'P',
blockAttributes: { style: 'font-size: 16px;' } 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 ### 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. 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. Squire is released under the MIT license. See LICENSE for full license.
API ## API
---
### addEventListener ### 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: 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. - **focus**: The editor gained focus.
* **blur**: The editor lost focus - **blur**: The editor lost focus
* **keydown**: Standard [DOM keydown event](https://developer.mozilla.org/en-US/docs/Web/Events/keydown). - **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). - **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). - **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. - **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. - **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. - **select**: The user selected some text.
* **cursor**: The user cleared their selection or moved the cursor to a - **cursor**: The user cleared their selection or moved the cursor to a
different position. 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. - **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 on the event object, or the `text` property for plain text being inserted into a `<pre>`. 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. - **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: The method takes two arguments:
* **type**: The event to listen for. e.g. 'focus'. - **type**: The event to listen for. e.g. 'focus'.
* **handler**: The callback function to invoke - **handler**: The callback function to invoke
Returns self (the Squire instance). Returns self (the Squire instance).
@ -121,8 +102,8 @@ Remove an event listener attached via the addEventListener method.
The method takes two arguments: The method takes two arguments:
* **type**: The event type the handler was registered for. - **type**: The event type the handler was registered for.
* **handler**: The handler to remove. - **handler**: The handler to remove.
Returns self (the Squire instance). 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: This method takes two arguments:
* **key**: The key to handle, including any modifiers in alphabetical order. e.g. `"alt-ctrl-meta-shift-enter"` - **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: - **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. - **self**: A reference to the Squire instance.
* **event**: The key event object. - **event**: The key event object.
* **range**: A Range object representing the current selection. - **range**: A Range object representing the current selection.
Returns self (the Squire instance). Returns self (the Squire instance).
@ -156,10 +137,6 @@ The method takes no arguments.
Returns self (the Squire instance). 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 ### getHTML
Returns the HTML value of the editor in its current state. This value is equivalent to the contents of the `<body>` tag and does not include any surrounding boilerplate. Returns the HTML value of the editor in its current state. This value is equivalent to the contents of the `<body>` 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 `<body
The method takes one argument: The method takes one argument:
* **html**: The html to set. - **html**: The html to set.
Returns self (the Squire instance). Returns self (the Squire instance).
@ -184,8 +161,8 @@ Inserts an image at the current cursor location.
The method takes two arguments: The method takes two arguments:
* **src**: The source path for the image. - **src**: The source path for the image.
* **attributes**: (optional) An object containing other attributes to set on the `<img>` node. e.g. `{ class: 'class-name' }`. Any `src` attribute will be overwritten by the url given as the first argument. - **attributes**: (optional) An object containing other attributes to set on the `<img>` 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. 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: The method takes one argument:
* **html**: The html to insert. - **html**: The html to insert.
Returns self (the Squire instance). Returns self (the Squire instance).
@ -205,16 +182,16 @@ Returns the path through the DOM tree from the `<body>` element to the current c
### getFontInfo ### 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 `<FONT>` 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 `<FONT>` tags or non-inline styles. If a selection across multiple elements has been made, it will return an empty object.
### createRange ### 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: 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** - **startContainer**
* **startOffset** - **startOffset**
* **endContainer** (optional; if not collapsed) - **endContainer** (optional; if not collapsed)
* **endOffset** (optional; if not collapsed) - **endOffset** (optional; if not collapsed)
### getCursorPosition ### getCursorPosition
@ -231,7 +208,7 @@ Changes the current selection/cursor position.
The method takes one argument: 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). Returns self (the Squire instance).
@ -252,7 +229,7 @@ Returns self (the Squire instance).
### saveUndoState ### saveUndoState
Saves an undo checkpoint with the current editor state. Methods that modify the 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 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. 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: The method takes two arguments:
* **tag**: The tag of the format - **tag**: The tag of the format
* **attributes**: (optional) Any attributes 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`. 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: This method takes two arguments:
* **url**: The url or email to link to. - **url**: The url or email to link to.
* **attributes**: (optional) An object containing other attributes to set on the `<a>` node. e.g. `{ target: '_blank' }`. Any `href` attribute will be overwritten by the url given as the first argument. - **attributes**: (optional) An object containing other attributes to set on the `<a>` node. e.g. `{ target: '_blank' }`. Any `href` attribute will be overwritten by the url given as the first argument.
Returns self (the Squire instance). Returns self (the Squire instance).
@ -340,7 +317,7 @@ Sets the font face for the selected text.
This method takes one argument: 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). Returns self (the Squire instance).
@ -350,27 +327,27 @@ Sets the font size for the selected text.
This method takes one argument: 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). 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: 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). 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: 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). 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: 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). 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: 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). 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: 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. - **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. - **mutates** A boolean indicating whether your function may modify anything in the document in any way.
Returns self (the Squire instance). 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: 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). Returns self (the Squire instance).
@ -499,5 +476,3 @@ This is useful when the document needs to be changed programmatically, but those
### linkRegExp ### 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. 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.

13
babel.config.cjs Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
'@babel/preset-typescript',
],
};

View file

@ -1,24 +0,0 @@
{
"name": "squire-rte",
"homepage": "https://github.com/neilj/Squire",
"authors": [
"Neil Jenkins <neil@nmjenkins.com>"
],
"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"
]
}

38
build.js Executable file
View file

@ -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));

View file

@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-squireinit="true">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title></title>
<style type="text/css">
html {
height: 100%;
}
body {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
height: 100%;
padding: 1em;
background: transparent;
color: #2b2b2b;
font: 13px/1.35 Helvetica, arial, sans-serif;
cursor: text;
}
a {
text-decoration: underline;
}
h1 {
font-size: 138.5%;
}
h2 {
font-size: 123.1%;
}
h3 {
font-size: 108%;
}
h1,h2,h3,p {
margin: 1em 0;
}
h4,h5,h6 {
margin: 0;
}
ul, ol {
margin: 0 1em;
padding: 0 1em;
}
blockquote {
border-left: 2px solid blue;
margin: 0;
padding: 0 10px;
}
</style>
</head>
<body>
<script type="text/javascript" src="squire.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

4066
dist/squire-raw.js vendored Normal file

File diff suppressed because it is too large Load diff

4089
dist/squire-raw.mjs vendored Normal file

File diff suppressed because it is too large Load diff

12
dist/squire.js vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/squire.js.map vendored Normal file

File diff suppressed because one or more lines are too long

12
dist/squire.mjs vendored Normal file

File diff suppressed because one or more lines are too long

7
dist/squire.mjs.map vendored Normal file

File diff suppressed because one or more lines are too long

15474
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,47 @@
{ {
"name": "squire-rte", "name": "squire-rte",
"version": "1.11.3", "version": "2.0.0",
"description": "Squire is an HTML5 rich text editor, which provides powerful cross-browser normalisation, whilst being supremely lightweight and flexible.", "description": "Squire is an HTML5 rich text editor, which provides powerful cross-browser normalisation, whilst being supremely lightweight and flexible.",
"main": "build/squire.js", "main": "dist/squire.mjs",
"scripts": { "type": "module",
"test": "echo \"Error: no test specified\" && exit 1" "scripts": {
}, "test": "jest",
"repository": { "build": "./build.js",
"type": "git", "lint": "eslint source",
"url": "https://github.com/neilj/Squire.git" "fix": "eslint --fix source && prettier --write source"
}, },
"keywords": [ "repository": {
"wysiwyg", "type": "git",
"editor", "url": "https://github.com/neilj/Squire.git"
"text", },
"html", "keywords": [
"squire" "wysiwyg",
], "editor",
"author": "Neil Jenkins", "text",
"license": "MIT", "html",
"bugs": { "squire"
"url": "https://github.com/neilj/Squire/issues" ],
}, "author": "Neil Jenkins",
"homepage": "https://github.com/neilj/Squire", "license": "MIT",
"devDependencies": { "bugs": {
"mocha": "2.2.5", "url": "https://github.com/neilj/Squire/issues"
"uglify-js": "^2.4.15", },
"unexpected": "8.2.0" "homepage": "https://github.com/neilj/Squire",
} "devDependencies": {
"@babel/core": "^7.20.12",
"@babel/preset-env": "^7.20.2",
"@babel/preset-typescript": "^7.18.6",
"@types/jest": "^28.1.6",
"@typescript-eslint/eslint-plugin": "^5.48.1",
"babel-jest": "^29.3.1",
"esbuild": "^0.16.17",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"jest": "^28.1.3",
"jest-environment-jsdom": "^29.3.1",
"prettier": "^2.8.2",
"tslib": "^2.0.1",
"typescript": "^4.7.4"
}
} }

16
prettier.config.cjs Normal file
View file

@ -0,0 +1,16 @@
/*global module */
module.exports = {
singleQuote: true,
tabWidth: 4,
quoteProps: 'consistent',
trailingComma: 'all',
overrides: [
{
files: ['*.html'],
options: {
tabWidth: 2,
printWidth: 1000,
},
},
],
};

View file

@ -1,345 +0,0 @@
/*jshint strict:false, undef:false, unused:false */
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 <o:p> 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() );
};
// <br> 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 <div>. Browsers that want <br>
// 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 <br> breaks a line first, because if we
// have two <br>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 );
}
}
};

391
source/Clean.ts Normal file
View file

@ -0,0 +1,391 @@
import { notWS } from './Constants';
import { TreeIterator, SHOW_ELEMENT_OR_TEXT } from './node/TreeIterator';
import { createElement, empty, detach } from './node/Node';
import { isInline, isLeaf } from './node/Category';
import { fixContainer } from './node/MergeSplit';
import { isLineBreak } from './node/Whitespace';
import type { SquireConfig } from './Editor';
// ---
type StyleRewriter = (
node: HTMLElement,
parent: Node,
config: SquireConfig,
) => HTMLElement;
// ---
const styleToSemantic: Record<
string,
{ regexp: RegExp; replace: (x: any, y: string) => HTMLElement }
> = {
'font-weight': {
regexp: /^bold|^700/i,
replace(): HTMLElement {
return createElement('B');
},
},
'font-style': {
regexp: /^italic/i,
replace(): HTMLElement {
return createElement('I');
},
},
'font-family': {
regexp: notWS,
replace(
classNames: { fontFamily: string },
family: string,
): HTMLElement {
return createElement('SPAN', {
class: classNames.fontFamily,
style: 'font-family:' + family,
});
},
},
'font-size': {
regexp: notWS,
replace(classNames: { fontSize: string }, size: string): HTMLElement {
return createElement('SPAN', {
class: classNames.fontSize,
style: 'font-size:' + size,
});
},
},
'text-decoration': {
regexp: /^underline/i,
replace(): HTMLElement {
return createElement('U');
},
},
};
const replaceStyles = (
node: HTMLElement,
_: Node,
config: SquireConfig,
): HTMLElement => {
const style = node.style;
let newTreeBottom: HTMLElement | undefined;
let newTreeTop: HTMLElement | undefined;
for (const attr in styleToSemantic) {
const converter = styleToSemantic[attr];
const css = style.getPropertyValue(attr);
if (css && converter.regexp.test(css)) {
const el = converter.replace(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.setProperty(attr, css);
}
}
if (newTreeTop && newTreeBottom) {
newTreeBottom.appendChild(empty(node));
node.appendChild(newTreeTop);
}
return newTreeBottom || node;
};
const replaceWithTag = (tag: string) => {
return (node: HTMLElement, parent: Node) => {
const el = createElement(tag);
const attributes = node.attributes;
for (let i = 0, l = attributes.length; i < l; i += 1) {
const attribute = attributes[i];
el.setAttribute(attribute.name, attribute.value);
}
parent.replaceChild(el, node);
el.appendChild(empty(node));
return el;
};
};
const fontSizes: Record<string, string> = {
'1': '10',
'2': '13',
'3': '16',
'4': '18',
'5': '24',
'6': '32',
'7': '48',
};
const stylesRewriters: Record<string, StyleRewriter> = {
STRONG: replaceWithTag('B'),
EM: replaceWithTag('I'),
INS: replaceWithTag('U'),
STRIKE: replaceWithTag('S'),
SPAN: replaceStyles,
FONT: (
node: HTMLElement,
parent: Node,
config: SquireConfig,
): HTMLElement => {
const font = node as HTMLFontElement;
const face = font.face;
const size = font.size;
let color = font.color;
const classNames = config.classNames;
let fontSpan: HTMLElement;
let sizeSpan: HTMLElement;
let colorSpan: HTMLElement;
let newTreeBottom: HTMLElement | undefined;
let newTreeTop: HTMLElement | undefined;
if (face) {
fontSpan = createElement('SPAN', {
class: classNames.fontFamily,
style: 'font-family:' + face,
});
newTreeTop = fontSpan;
newTreeBottom = fontSpan;
}
if (size) {
sizeSpan = createElement('SPAN', {
class: classNames.fontSize,
style: 'font-size:' + fontSizes[size] + 'px',
});
if (!newTreeTop) {
newTreeTop = sizeSpan;
}
if (newTreeBottom) {
newTreeBottom.appendChild(sizeSpan);
}
newTreeBottom = sizeSpan;
}
if (color && /^#?([\dA-F]{3}){1,2}$/i.test(color)) {
if (color.charAt(0) !== '#') {
color = '#' + color;
}
colorSpan = createElement('SPAN', {
class: classNames.color,
style: 'color:' + color,
});
if (!newTreeTop) {
newTreeTop = colorSpan;
}
if (newTreeBottom) {
newTreeBottom.appendChild(colorSpan);
}
newTreeBottom = colorSpan;
}
if (!newTreeTop || !newTreeBottom) {
newTreeTop = newTreeBottom = createElement('SPAN');
}
parent.replaceChild(newTreeTop, font);
newTreeBottom.appendChild(empty(font));
return newTreeBottom;
},
TT: (node: Node, parent: Node, config: SquireConfig): HTMLElement => {
const el = createElement('SPAN', {
class: config.classNames.fontFamily,
style: 'font-family:menlo,consolas,"courier new",monospace',
});
parent.replaceChild(el, node);
el.appendChild(empty(node));
return el;
},
};
const 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)$/;
const blacklist = /^(?:HEAD|META|STYLE)/;
/*
Two purposes:
1. Remove nodes we don't want, such as weird <o:p> tags, comment nodes
and whitespace nodes.
2. Convert inline tags into our preferred format.
*/
const cleanTree = (
node: Node,
config: SquireConfig,
preserveWS?: boolean,
): Node => {
const children = node.childNodes;
let nonInlineParent = node;
while (isInline(nonInlineParent)) {
nonInlineParent = nonInlineParent.parentNode!;
}
const walker = new TreeIterator<Element | Text>(
nonInlineParent,
SHOW_ELEMENT_OR_TEXT,
);
for (let i = 0, l = children.length; i < l; i += 1) {
let child = children[i];
const nodeName = child.nodeName;
const rewriter = stylesRewriters[nodeName];
if (child instanceof HTMLElement) {
const 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 (child instanceof Text) {
let data = child.data;
const startsWithWS = !notWS.test(data.charAt(0));
const 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;
let sibling;
while ((sibling = walker.previousPONode())) {
if (
sibling.nodeName === 'IMG' ||
(sibling instanceof Text &&
notWS.test(sibling.data))
) {
break;
}
if (!isInline(sibling)) {
sibling = null;
break;
}
}
data = data.replace(/^[ \t\r\n]+/g, sibling ? ' ' : '');
}
if (endsWithWS) {
walker.currentNode = child;
let sibling;
while ((sibling = walker.nextNode())) {
if (
sibling.nodeName === 'IMG' ||
(sibling instanceof Text &&
notWS.test(sibling.data))
) {
break;
}
if (!isInline(sibling)) {
sibling = null;
break;
}
}
data = data.replace(/[ \t\r\n]+$/g, sibling ? ' ' : '');
}
if (data) {
child.data = data;
continue;
}
}
node.removeChild(child);
i -= 1;
l -= 1;
}
}
return node;
};
// ---
const removeEmptyInlines = (node: Node): void => {
const children = node.childNodes;
let l = children.length;
while (l--) {
const child = children[l];
if (child instanceof Element && !isLeaf(child)) {
removeEmptyInlines(child);
if (isInline(child) && !child.firstChild) {
node.removeChild(child);
}
} else if (child instanceof Text && !child.data) {
node.removeChild(child);
}
}
};
// ---
// <br> 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 <div>. Browsers that want <br>
// elements at the end of each block will then have them added back in a later
// fixCursor method call.
const cleanupBRs = (
node: Element | DocumentFragment,
root: Element,
keepForBlankLine: boolean,
): void => {
const brs: NodeListOf<HTMLBRElement> = node.querySelectorAll('BR');
const brBreaksLine: boolean[] = [];
let l = brs.length;
// Must calculate whether the <br> breaks a line first, because if we
// have two <br>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 (let i = 0; i < l; i += 1) {
brBreaksLine[i] = isLineBreak(brs[i], keepForBlankLine);
}
while (l--) {
const br = brs[l];
// Cleanup may have removed it
const 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);
}
}
};
// ---
const escapeHTML = (text: string): string => {
return text
.split('&')
.join('&amp;')
.split('<')
.join('&lt;')
.split('>')
.join('&gt;')
.split('"')
.join('&quot;');
};
// ---
export { cleanTree, cleanupBRs, isLineBreak, removeEmptyInlines, escapeHTML };

View file

@ -1,351 +0,0 @@
/*jshint strict:false, undef:false, unused:false */
// 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();
}
};

422
source/Clipboard.ts Normal file
View file

@ -0,0 +1,422 @@
import { cleanupBRs } from './Clean';
import { isWin, isGecko, isLegacyEdge, notWS } from './Constants';
import { createElement, detach } from './node/Node';
import { getStartBlockOfRange, getEndBlockOfRange } from './range/Block';
import { createRange, deleteContentsOfRange } from './range/InsertDelete';
import {
moveRangeBoundariesDownTree,
moveRangeBoundariesUpTree,
} from './range/Boundaries';
import type { Squire } from './Editor';
// ---
const indexOf = Array.prototype.indexOf;
// 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.
const setClipboardData = (
event: ClipboardEvent,
contents: Node,
root: HTMLElement,
toCleanHTML: null | ((html: string) => string),
toPlainText: null | ((html: string) => string),
plainTextOnly: boolean,
): void => {
const clipboardData = event.clipboardData!;
const body = document.body;
const node = createElement('DIV') as HTMLDivElement;
let html: string | undefined;
let text: string | undefined;
if (
contents.childNodes.length === 1 &&
contents.childNodes[0] instanceof Text
) {
// Replace nbsp with regular space;
// eslint-disable-next-line no-irregular-whitespace
text = contents.childNodes[0].data.replace(/ /g, ' ');
plainTextOnly = true;
} else {
node.appendChild(contents);
html = node.innerHTML;
if (toCleanHTML) {
html = toCleanHTML(html);
}
}
if (text !== undefined) {
// Do nothing; we were copying plain text to start
} else if (toPlainText && html !== undefined) {
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!;
// Replace nbsp with regular space
// eslint-disable-next-line no-irregular-whitespace
text = text.replace(/ /g, ' ');
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 && html && text !== html) {
clipboardData.setData('text/html', html);
}
clipboardData.setData('text/plain', text);
event.preventDefault();
};
const extractRangeToClipboard = (
event: ClipboardEvent,
range: Range,
root: HTMLElement,
removeRangeFromDocument: boolean,
toCleanHTML: null | ((html: string) => string),
toPlainText: null | ((html: string) => string),
plainTextOnly: boolean,
): boolean => {
// Edge only seems to support setting plain text as of 2016-03-11.
if (!isLegacyEdge && event.clipboardData) {
// Clipboard content should include all parents within block, or all
// parents up to root if selection across blocks
const startBlock = getStartBlockOfRange(range, root);
const endBlock = getEndBlockOfRange(range, root);
let copyRoot = root;
// If the content is not in well-formed blocks, the start and end block
// may be the same, but actually the range goes outside it. Must check!
if (
startBlock === endBlock &&
startBlock?.contains(range.commonAncestorContainer)
) {
copyRoot = startBlock;
}
// Extract the contents
let contents: Node;
if (removeRangeFromDocument) {
contents = deleteContentsOfRange(range, root);
} else {
// 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);
contents = range.cloneContents();
}
// Add any other parents not in extracted content, up to copy root
let parent = range.commonAncestorContainer;
if (parent instanceof Text) {
parent = parent.parentNode!;
}
while (parent && parent !== copyRoot) {
const newContents = parent.cloneNode(false);
newContents.appendChild(contents);
contents = newContents;
parent = parent.parentNode!;
}
// Set clipboard data
setClipboardData(
event,
contents,
root,
toCleanHTML,
toPlainText,
plainTextOnly,
);
return true;
}
return false;
};
// ---
const _onCut = function (this: Squire, event: ClipboardEvent): void {
const range: Range = this.getSelection();
const root: HTMLElement = this._root;
// Nothing to do
if (range.collapsed) {
event.preventDefault();
return;
}
// Save undo checkpoint
this.saveUndoState(range);
const handled = extractRangeToClipboard(
event,
range,
root,
true,
this._config.willCutCopy,
null,
false,
);
if (!handled) {
setTimeout(() => {
try {
// If all content removed, ensure div at start of root.
this._ensureBottomLine();
} catch (error) {
this._config.didError(error);
}
}, 0);
}
this.setSelection(range);
};
const _onCopy = function (this: Squire, event: ClipboardEvent): void {
extractRangeToClipboard(
event,
this.getSelection(),
this._root,
false,
this._config.willCutCopy,
null,
false,
);
};
// Need to monitor for shift key like this, as event.shiftKey is not available
// in paste event.
const _monitorShiftKey = function (this: Squire, event: KeyboardEvent): void {
this._isShiftDown = event.shiftKey;
};
const _onPaste = function (this: Squire, event: ClipboardEvent): void {
const clipboardData = event.clipboardData;
const items = clipboardData?.items;
const choosePlain: boolean | undefined = this._isShiftDown;
let hasRTF = false;
let hasImage = false;
let plainItem: null | DataTransferItem = null;
let htmlItem: null | DataTransferItem = null;
// Current HTML5 Clipboard interface
// ---------------------------------
// https://html.spec.whatwg.org/multipage/interaction.html
if (items) {
let l = items.length;
while (l--) {
const item = items[l];
const 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('pasteImage', {
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 (!isLegacyEdge) {
event.preventDefault();
if (htmlItem && (!choosePlain || !plainItem)) {
htmlItem.getAsString((html) => {
this.insertHTML(html, true);
});
} else if (plainItem) {
plainItem.getAsString((text) => {
// If we have a selection and text is solely a URL,
// just make the text a link.
let isLink = false;
const range = this.getSelection();
if (!range.collapsed && notWS.test(range.toString())) {
const match = this.linkRegExp.exec(text);
isLink = !!match && match[0].length === text.length;
}
if (isLink) {
this.makeLink(text);
} else {
this.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
const types = clipboardData?.types;
if (
!isLegacyEdge &&
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.
let data;
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 :(
// --------------------------------------------
const body = document.body;
const range = this.getSelection();
const startContainer = range.startContainer;
const startOffset = range.startOffset;
const endContainer = range.endContainer;
const endOffset = range.endOffset;
// We need to position the pasteArea in the visible portion of the screen
// to stop the browser auto-scrolling.
let pasteArea: Element = 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(() => {
try {
// Get the pasted content and clean
let html = '';
let next: Element = pasteArea;
let first: Node | null;
// #88: Chrome can apparently split the paste area if certain
// content is inserted; gather them all up.
while ((pasteArea = next)) {
next = pasteArea.nextSibling as Element;
detach(pasteArea);
// Safari and IE like putting extra divs around things.
first = pasteArea.firstChild;
if (
first &&
first === pasteArea.lastChild &&
first instanceof HTMLDivElement
) {
pasteArea = first;
}
html += pasteArea.innerHTML;
}
this.setSelection(
createRange(
startContainer,
startOffset,
endContainer,
endOffset,
),
);
if (html) {
this.insertHTML(html, true);
}
} catch (error) {
this._config.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.
const _onDrop = function (this: Squire, event: DragEvent): void {
// it's possible for dataTransfer to be null, let's avoid it.
if (!event.dataTransfer) {
return;
}
const types = event.dataTransfer.types;
let l = types.length;
let hasPlain = false;
let 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)) {
this.saveUndoState();
}
};
// ---
export {
extractRangeToClipboard,
_onCut,
_onCopy,
_monitorShiftKey,
_onPaste,
_onDrop,
};

View file

@ -1,43 +0,0 @@
/*jshint strict:false, undef:false, unused:false */
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;

52
source/Constants.ts Normal file
View file

@ -0,0 +1,52 @@
const DOCUMENT_POSITION_PRECEDING = 2; // Node.DOCUMENT_POSITION_PRECEDING
const ELEMENT_NODE = 1; // Node.ELEMENT_NODE;
const TEXT_NODE = 3; // Node.TEXT_NODE;
const DOCUMENT_NODE = 9; // Node.DOCUMENT_NODE;
const DOCUMENT_FRAGMENT_NODE = 11; // Node.DOCUMENT_FRAGMENT_NODE;
const ZWS = '\u200B';
const ua = navigator.userAgent;
const isMac = /Mac OS X/.test(ua);
const isWin = /Windows NT/.test(ua);
const isIOS =
/iP(?:ad|hone|od)/.test(ua) || (isMac && !!navigator.maxTouchPoints);
const isAndroid = /Android/.test(ua);
const isGecko = /Gecko\//.test(ua);
const isLegacyEdge = /Edge\//.test(ua);
const isWebKit = !isLegacyEdge && /WebKit\//.test(ua);
const ctrlKey = isMac || isIOS ? 'Meta-' : 'Ctrl-';
const cantFocusEmptyTextNodes = isWebKit;
const supportsInputEvents =
'onbeforeinput' in document && 'inputType' in new InputEvent('input');
// Use [^ \t\r\n] instead of \S so that nbsp does not count as white-space
const notWS = /[^ \t\r\n]/;
// ---
export {
DOCUMENT_POSITION_PRECEDING,
ELEMENT_NODE,
TEXT_NODE,
DOCUMENT_NODE,
DOCUMENT_FRAGMENT_NODE,
notWS,
ZWS,
ua,
isMac,
isWin,
isIOS,
isAndroid,
isGecko,
isLegacyEdge,
isWebKit,
ctrlKey,
cantFocusEmptyTextNodes,
supportsInputEvents,
};

File diff suppressed because it is too large Load diff

2816
source/Editor.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,625 +0,0 @@
/*jshint strict:false, undef:false, unused:false */
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 <font> 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 <div> in Chrome,
// it removes the div and replaces it with just a <br> inside the
// root. Detach the <br>; 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 <br>.
if ( !block || shiftKey || /^T[HD]$/.test( block.nodeName ) ) {
// If inside an <a>, 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 <b>/<i> 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 (<a>foo|</a>) then move it
// outside of the link (<a>foo</a>|) 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' );

3
source/Legacy.ts Normal file
View file

@ -0,0 +1,3 @@
import { Squire } from './Editor';
(window as any).Squire = Squire;

View file

@ -1,518 +0,0 @@
/*jshint strict:false, undef:false, unused:false */
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 <BR> 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 <BR> 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 );
}
}

View file

@ -1,572 +0,0 @@
/*jshint strict:false, undef:false, unused:false, latedef:false */
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. <blockquote><p>Foo</p></blockquote>
// 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 <br>  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 );
}
};

3
source/Squire.ts Normal file
View file

@ -0,0 +1,3 @@
import { Squire } from './Editor';
export default Squire;

View file

@ -1,122 +0,0 @@
/*jshint strict:false */
/*
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;
}
};

View file

@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-squireinit="true">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title></title>
<style type="text/css">
html {
height: 100%;
}
body {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
height: 100%;
padding: 1em;
background: transparent;
color: #2b2b2b;
font: 13px/1.35 Helvetica, arial, sans-serif;
cursor: text;
}
a {
text-decoration: underline;
}
h1 {
font-size: 138.5%;
}
h2 {
font-size: 123.1%;
}
h3 {
font-size: 108%;
}
h1,h2,h3,p {
margin: 1em 0;
}
h4,h5,h6 {
margin: 0;
}
ul, ol {
margin: 0 1em;
padding: 0 1em;
}
blockquote {
border-left: 2px solid blue;
margin: 0;
padding: 0 10px;
}
</style>
</head>
<body>
<script type="text/javascript" src="squire.js"></script>
</body>
</html>

View file

@ -1,43 +0,0 @@
/*jshint ignore:start */
// Node.js exports
Squire.isInline = isInline;
Squire.isBlock = isBlock;
Squire.isContainer = isContainer;
Squire.getBlockWalker = getBlockWalker;
Squire.getPreviousBlock = getPreviousBlock;
Squire.getNextBlock = getNextBlock;
Squire.areAlike = areAlike;
Squire.hasTagAttributes = hasTagAttributes;
Squire.getNearest = getNearest;
Squire.isOrContains = isOrContains;
Squire.detach = detach;
Squire.replaceWith = replaceWith;
Squire.empty = empty;
// Range.js exports
Squire.getNodeBefore = getNodeBefore;
Squire.getNodeAfter = getNodeAfter;
Squire.insertNodeInRange = insertNodeInRange;
Squire.extractContentsOfRange = extractContentsOfRange;
Squire.deleteContentsOfRange = deleteContentsOfRange;
Squire.insertTreeFragmentIntoRange = insertTreeFragmentIntoRange;
Squire.isNodeContainedInRange = isNodeContainedInRange;
Squire.moveRangeBoundariesDownTree = moveRangeBoundariesDownTree;
Squire.moveRangeBoundariesUpTree = moveRangeBoundariesUpTree;
Squire.getStartBlockOfRange = getStartBlockOfRange;
Squire.getEndBlockOfRange = getEndBlockOfRange;
Squire.contentWalker = contentWalker;
Squire.rangeDoesStartAtBlockBoundary = rangeDoesStartAtBlockBoundary;
Squire.rangeDoesEndAtBlockBoundary = rangeDoesEndAtBlockBoundary;
Squire.expandRangeToBlockBoundaries = expandRangeToBlockBoundaries;
// Clipboard.js exports
Squire.onCopy = _onCopy;
Squire.onPaste = onPaste;
// Editor.js exports
Squire.addLinks = addLinks;
Squire.splitBlock = splitBlock;
Squire.startSelectionId = startSelectionId;
Squire.endSelectionId = endSelectionId;

View file

@ -1,6 +0,0 @@
/* Copyright © 2011-2015 by Neil Jenkins. MIT Licensed. */
/*jshint ignore:start */
( function ( doc, undefined ) {
"use strict";

View file

@ -0,0 +1,92 @@
import type { Squire } from '../Editor';
import { getPreviousBlock } from '../node/Block';
import {
fixContainer,
mergeContainers,
mergeWithBlock,
} from '../node/MergeSplit';
import { getNearest } from '../node/Node';
import {
getStartBlockOfRange,
rangeDoesStartAtBlockBoundary,
} from '../range/Block';
import { deleteContentsOfRange } from '../range/InsertDelete';
import { afterDelete, detachUneditableNode } from './KeyHelpers';
// ---
const Backspace = (self: Squire, event: KeyboardEvent, range: Range): void => {
const root: Element = 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();
const startBlock = getStartBlockOfRange(range, root);
if (!startBlock) {
return;
}
let current = startBlock;
// In case inline data has somehow got between blocks.
fixContainer(current.parentNode!, root);
// Now get previous block
const 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 as HTMLElement).isContentEditable) {
detachUneditableNode(previous, root);
return;
}
// Otherwise merge.
mergeWithBlock(previous, current, range, root);
// If deleted line between containers, merge newly adjacent
// containers.
current = previous.parentNode as HTMLElement;
while (current !== root && !current.nextSibling) {
current = current.parentNode as HTMLElement;
}
if (
current !== root &&
(current = current.nextSibling as HTMLElement)
) {
mergeContainers(current, root);
}
self.setSelection(range);
// If at very beginning of text area, allow backspace
// to break lists/blockquote.
} else if (current) {
if (
getNearest(current, root, 'UL') ||
getNearest(current, root, 'OL')
) {
// Break list
self.decreaseListLevel(range);
return;
} else if (getNearest(current, root, 'BLOCKQUOTE')) {
// Break blockquote
self.removeQuote(range);
return;
}
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(() => {
afterDelete(self);
}, 0);
}
};
// ---
export { Backspace };

100
source/keyboard/Delete.ts Normal file
View file

@ -0,0 +1,100 @@
import { getNextBlock } from '../node/Block';
import {
fixContainer,
mergeWithBlock,
mergeContainers,
} from '../node/MergeSplit';
import { detach } from '../node/Node';
import {
rangeDoesEndAtBlockBoundary,
getStartBlockOfRange,
} from '../range/Block';
import {
moveRangeBoundariesUpTree,
moveRangeBoundariesDownTree,
} from '../range/Boundaries';
import { deleteContentsOfRange } from '../range/InsertDelete';
import { afterDelete, detachUneditableNode } from './KeyHelpers';
import type { Squire } from '../Editor';
// ---
const Delete = (self: Squire, event: KeyboardEvent, range: Range): void => {
const root = self._root;
let current: Node | null;
let next: Node | null;
let originalRange: Range;
let cursorContainer: Node;
let cursorOffset: number;
let nodeAfterCursor: Node;
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 as HTMLElement).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 instanceof Element) {
nodeAfterCursor = cursorContainer.childNodes[cursorOffset];
if (nodeAfterCursor && nodeAfterCursor.nodeName === 'IMG') {
event.preventDefault();
detach(nodeAfterCursor);
moveRangeBoundariesDownTree(range);
afterDelete(self, range);
return;
}
}
self.setSelection(originalRange);
setTimeout(() => {
afterDelete(self);
}, 0);
}
};
// ---
export { Delete };

12
source/keyboard/Enter.ts Normal file
View file

@ -0,0 +1,12 @@
import type { Squire } from '../Editor';
// ---
const Enter = (self: Squire, event: KeyboardEvent, range: Range): void => {
event.preventDefault();
self.splitBlock(event.shiftKey, range);
};
// ---
export { Enter };

View file

@ -0,0 +1,227 @@
import {
isMac,
isWin,
isIOS,
ctrlKey,
supportsInputEvents,
} from '../Constants';
import { deleteContentsOfRange } from '../range/InsertDelete';
import type { Squire } from '../Editor';
import { Enter } from './Enter';
import { Backspace } from './Backspace';
import { Delete } from './Delete';
import { ShiftTab, Tab } from './Tab';
import { Space } from './Space';
// ---
const keys: Record<string, string> = {
8: 'Backspace',
9: 'Tab',
13: 'Enter',
27: 'Escape',
32: 'Space',
33: 'PageUp',
34: 'PageDown',
37: 'ArrowLeft',
38: 'ArrowUp',
39: 'ArrowRight',
40: 'ArrowDown',
46: 'Delete',
191: '/',
219: '[',
220: '\\',
221: ']',
};
// Ref: http://unixpapa.com/js/key.html
const _onKey = function (this: Squire, event: KeyboardEvent): void {
const code = event.keyCode;
let key = keys[code];
let modifiers = '';
const range: 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);
} else if (
!range.collapsed &&
// !event.isComposing stops us from blatting Kana-Kanji conversion in
// Safari
!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);
}
};
// ---
type KeyHandler = (self: Squire, event: KeyboardEvent, range: Range) => void;
const keyHandlers: Record<string, KeyHandler> = {
'Backspace': Backspace,
'Delete': Delete,
'Tab': Tab,
'Shift-Tab': ShiftTab,
'Space': Space,
'ArrowLeft'(self: Squire): void {
self._removeZWS();
},
'ArrowRight'(self: Squire): void {
self._removeZWS();
},
};
if (!supportsInputEvents) {
keyHandlers.Enter = Enter;
keyHandlers['Shift-Enter'] = Enter;
}
// System standard for page up/down on Mac/iOS 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 && !isIOS) {
keyHandlers.PageUp = (self: Squire) => {
self.moveCursorToStart();
};
keyHandlers.PageDown = (self: Squire) => {
self.moveCursorToEnd();
};
}
// ---
const mapKeyToFormat = (
tag: string,
remove?: { tag: string } | null,
): KeyHandler => {
remove = remove || null;
return (self: Squire, event: Event) => {
event.preventDefault();
const range = self.getSelection();
if (self.hasFormat(tag, null, range)) {
self.changeFormat(null, { tag }, range);
} else {
self.changeFormat({ tag }, remove, range);
}
};
};
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'] = (
self: Squire,
event: KeyboardEvent,
): void => {
event.preventDefault();
const path = self.getPath();
if (!/(?:^|>)UL/.test(path)) {
self.makeUnorderedList();
} else {
self.removeList();
}
};
keyHandlers[ctrlKey + 'Shift-9'] = (
self: Squire,
event: KeyboardEvent,
): void => {
event.preventDefault();
const path = self.getPath();
if (!/(?:^|>)OL/.test(path)) {
self.makeOrderedList();
} else {
self.removeList();
}
};
keyHandlers[ctrlKey + '['] = (self: Squire, event: KeyboardEvent): void => {
event.preventDefault();
const path = self.getPath();
if (/(?:^|>)BLOCKQUOTE/.test(path) || !/(?:^|>)[OU]L/.test(path)) {
self.decreaseQuoteLevel();
} else {
self.decreaseListLevel();
}
};
keyHandlers[ctrlKey + ']'] = (self: Squire, event: KeyboardEvent): void => {
event.preventDefault();
const path = self.getPath();
if (/(?:^|>)BLOCKQUOTE/.test(path) || !/(?:^|>)[OU]L/.test(path)) {
self.increaseQuoteLevel();
} else {
self.increaseListLevel();
}
};
keyHandlers[ctrlKey + 'd'] = (self: Squire, event: KeyboardEvent): void => {
event.preventDefault();
self.toggleCode();
};
keyHandlers[ctrlKey + 'z'] = (self: Squire, event: KeyboardEvent): void => {
event.preventDefault();
self.undo();
};
keyHandlers[ctrlKey + 'y'] = keyHandlers[ctrlKey + 'Shift-z'] = (
self: Squire,
event: KeyboardEvent,
): void => {
event.preventDefault();
self.redo();
};
export { _onKey, keyHandlers };

View file

@ -0,0 +1,140 @@
import { ZWS } from '../Constants';
import { getPreviousBlock } from '../node/Block';
import { isInline, isBlock } from '../node/Category';
import { fixCursor } from '../node/MergeSplit';
import { createElement, detach, getNearest } from '../node/Node';
import { moveRangeBoundariesDownTree } from '../range/Boundaries';
import type { Squire } from '../Editor';
// ---
// If you delete the content inside a span with a font styling, Webkit will
// replace it with a <font> 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.
const afterDelete = (self: Squire, range?: Range): void => {
try {
if (!range) {
range = self.getSelection();
}
let node = range!.startContainer;
// Climb the tree from the focus point while we are inside an empty
// inline element
if (node instanceof Text) {
node = node.parentNode!;
}
let 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,
Array.from(parent.childNodes as NodeListOf<Node>).indexOf(node),
);
range!.collapse(true);
// Remove empty inline(s)
parent.removeChild(node);
// Fix cursor in block
if (!isBlock(parent)) {
parent = getPreviousBlock(parent, self._root) || self._root;
}
fixCursor(parent);
// Move cursor into text node
moveRangeBoundariesDownTree(range!);
}
// If you delete the last character in the sole <div> in Chrome,
// it removes the div and replaces it with just a <br> inside the
// root. Detach the <br>; 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._config.didError(error);
}
};
const detachUneditableNode = (node: Node, root: Element): void => {
let parent: Node | null;
while ((parent = node.parentNode)) {
if (parent === root || (parent as HTMLElement).isContentEditable) {
break;
}
node = parent;
}
detach(node);
};
// ---
const linkifyText = (self: Squire, textNode: Text, offset: number): void => {
if (getNearest(textNode, self._root, 'A')) {
return;
}
const data = textNode.data || '';
const searchFrom =
Math.max(
data.lastIndexOf(' ', offset - 1),
data.lastIndexOf(' ', offset - 1),
) + 1;
const searchText = data.slice(searchFrom, offset);
const match = self.linkRegExp.exec(searchText);
if (match) {
// Record an undo point
const selection = self.getSelection();
self._docWasChanged();
self._recordUndoState(selection);
self._getRangeAndRemoveBookmark(selection);
const index = searchFrom + match.index;
const endIndex = index + match[0].length;
if (index) {
textNode = textNode.splitText(index);
}
const defaultAttributes = self._config.tagAttributes.a;
const link = createElement(
'A',
Object.assign(
{
href: match[1]
? /^(?:ht|f)tps?:/i.test(match[1])
? match[1]
: 'http://' + match[1]
: 'mailto:' + match[0],
},
defaultAttributes,
),
);
link.textContent = data.slice(index, endIndex);
textNode.parentNode!.insertBefore(link, textNode);
const startOffset = selection.startOffset;
textNode.data = data.slice(endIndex);
if (selection.startContainer === textNode) {
const newOffset = startOffset - endIndex;
selection.setStart(textNode, newOffset);
selection.setEnd(textNode, newOffset);
}
self.setSelection(selection);
}
};
// ---
export { afterDelete, detachUneditableNode, linkifyText };

57
source/keyboard/Space.ts Normal file
View file

@ -0,0 +1,57 @@
import { getLength } from '../node/Node';
import { moveRangeBoundariesDownTree } from '../range/Boundaries';
import { deleteContentsOfRange } from '../range/InsertDelete';
import type { Squire } from '../Editor';
import { linkifyText } from './KeyHelpers';
// ---
const Space = (self: Squire, _: KeyboardEvent, range: Range): void => {
let node: Node | null;
const root = self._root;
self._recordUndoState(range);
self._getRangeAndRemoveBookmark(range);
// Delete the selection if not collapsed
if (!range.collapsed) {
deleteContentsOfRange(range, root);
self._ensureBottomLine();
self.setSelection(range);
self._updatePath(range, true);
}
// If the cursor is at the end of a link (<a>foo|</a>) then move it
// outside of the link (<a>foo</a>|) so that the space is not part of
// the link text.
node = range.endContainer;
if (range.endOffset === getLength(node)) {
do {
if (node.nodeName === 'A') {
range.setStartAfter(node);
break;
}
} while (
!node.nextSibling &&
(node = node.parentNode) &&
node !== root
);
}
// Linkify text
if (self._config.addLinks) {
const linkRange = range.cloneRange();
moveRangeBoundariesDownTree(linkRange);
const textNode = linkRange.startContainer as Text;
const offset = linkRange.startOffset;
setTimeout(() => {
linkifyText(self, textNode, offset);
}, 0);
}
self.setSelection(range);
};
// ---
export { Space };

48
source/keyboard/Tab.ts Normal file
View file

@ -0,0 +1,48 @@
import {
rangeDoesStartAtBlockBoundary,
getStartBlockOfRange,
} from '../range/Block';
import { getNearest } from '../node/Node';
import type { Squire } from '../Editor';
// ---
const Tab = (self: Squire, event: KeyboardEvent, range: Range): void => {
const root = self._root;
self._removeZWS();
// If no selection and at start of block
if (range.collapsed && rangeDoesStartAtBlockBoundary(range, root)) {
let node: Node = getStartBlockOfRange(range, root)!;
// Iterate through the block's parents
let parent: Node | null;
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;
}
}
};
const ShiftTab = (self: Squire, event: KeyboardEvent, range: Range): void => {
const root = self._root;
self._removeZWS();
// If no selection and at start of block
if (range.collapsed && rangeDoesStartAtBlockBoundary(range, root)) {
// Break list
const node = range.startContainer;
if (getNearest(node, root, 'UL') || getNearest(node, root, 'OL')) {
event.preventDefault();
self.decreaseListLevel(range);
}
}
};
// ---
export { Tab, ShiftTab };

37
source/node/Block.ts Normal file
View file

@ -0,0 +1,37 @@
import { TreeIterator, SHOW_ELEMENT } from './TreeIterator';
import { isBlock } from './Category';
// ---
const getBlockWalker = (
node: Node,
root: Element | DocumentFragment,
): TreeIterator<HTMLElement> => {
const walker = new TreeIterator<HTMLElement>(root, SHOW_ELEMENT, isBlock);
walker.currentNode = node;
return walker;
};
const getPreviousBlock = (
node: Node,
root: Element | DocumentFragment,
): HTMLElement | null => {
const block = getBlockWalker(node, root).previousNode();
return block !== root ? block : null;
};
const getNextBlock = (
node: Node,
root: Element | DocumentFragment,
): HTMLElement | null => {
const block = getBlockWalker(node, root).nextNode();
return block !== root ? block : null;
};
const isEmptyBlock = (block: Element): boolean => {
return !block.textContent && !block.querySelector('IMG');
};
// ---
export { getBlockWalker, getPreviousBlock, getNextBlock, isEmptyBlock };

79
source/node/Category.ts Normal file
View file

@ -0,0 +1,79 @@
import { ELEMENT_NODE, TEXT_NODE, DOCUMENT_FRAGMENT_NODE } from '../Constants';
// ---
const 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)$/;
const leafNodeNames = new Set(['BR', 'HR', 'IFRAME', 'IMG', 'INPUT']);
const UNKNOWN = 0;
const INLINE = 1;
const BLOCK = 2;
const CONTAINER = 3;
// ---
let cache: WeakMap<Node, number> = new WeakMap();
const resetNodeCategoryCache = (): void => {
cache = new WeakMap();
};
// ---
const isLeaf = (node: Node): boolean => {
return leafNodeNames.has(node.nodeName);
};
const getNodeCategory = (node: Node): number => {
switch (node.nodeType) {
case TEXT_NODE:
return INLINE;
case ELEMENT_NODE:
case DOCUMENT_FRAGMENT_NODE:
if (cache.has(node)) {
return cache.get(node) as number;
}
break;
default:
return UNKNOWN;
}
let nodeCategory: number;
if (!Array.from(node.childNodes).every(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;
}
cache.set(node, nodeCategory);
return nodeCategory;
};
const isInline = (node: Node): boolean => {
return getNodeCategory(node) === INLINE;
};
const isBlock = (node: Node): boolean => {
return getNodeCategory(node) === BLOCK;
};
const isContainer = (node: Node): boolean => {
return getNodeCategory(node) === CONTAINER;
};
// ---
export {
getNodeCategory,
isBlock,
isContainer,
isInline,
isLeaf,
leafNodeNames,
resetNodeCategoryCache,
};

308
source/node/MergeSplit.ts Normal file
View file

@ -0,0 +1,308 @@
import { ZWS, cantFocusEmptyTextNodes } from '../Constants';
import {
createElement,
getNearest,
areAlike,
getLength,
detach,
empty,
} from './Node';
import { isInline, isContainer } from './Category';
// ---
const fixCursor = (node: Node): Node => {
// In Webkit and Gecko, block level elements are collapsed and
// unfocusable if they have no content. To remedy this, a <BR> must be
// inserted. In Opera and IE, we just need a textnode in order for the
// cursor to appear.
let fixer: Element | Text | null = null;
if (node instanceof Text) {
return node;
}
if (isInline(node)) {
let child = node.firstChild;
if (cantFocusEmptyTextNodes) {
while (child && child instanceof Text && !child.data) {
node.removeChild(child);
child = node.firstChild;
}
}
if (!child) {
if (cantFocusEmptyTextNodes) {
fixer = document.createTextNode(ZWS);
} else {
fixer = document.createTextNode('');
}
}
} else if (node instanceof Element && !node.querySelector('BR')) {
fixer = createElement('BR');
let parent: Element = node;
let child: Element | null;
while ((child = parent.lastElementChild) && !isInline(child)) {
parent = child;
}
}
if (fixer) {
try {
node.appendChild(fixer);
} catch (error) {}
}
return node;
};
// Recursively examine container nodes and wrap any inline children.
const fixContainer = (
container: Node,
root: Element | DocumentFragment,
): Node => {
const children = container.childNodes;
let wrapper: HTMLElement | null = null;
for (let i = 0, l = children.length; i < l; i += 1) {
const child = children[i];
const isBR = child.nodeName === 'BR';
if (!isBR && isInline(child)) {
if (!wrapper) {
wrapper = createElement('DIV');
}
wrapper.appendChild(child);
i -= 1;
l -= 1;
} else if (isBR || wrapper) {
if (!wrapper) {
wrapper = createElement('DIV');
}
fixCursor(wrapper);
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));
}
return container;
};
const split = (
node: Node,
offset: number | Node | null,
stopNode: Node,
root: Element | DocumentFragment,
): Node | null => {
if (node instanceof Text && node !== stopNode) {
if (typeof offset !== 'number') {
throw new Error('Offset must be a number to split text node!');
}
if (!node.parentNode) {
throw new Error('Cannot split text node with no parent!');
}
return split(node.parentNode, node.splitText(offset), stopNode, root);
}
let nodeAfterSplit: Node | null =
typeof offset === 'number'
? offset < node.childNodes.length
? node.childNodes[offset]
: null
: offset;
const parent = node.parentNode;
if (!parent || node === stopNode || !(node instanceof Element)) {
return nodeAfterSplit;
}
// Clone node without children
const clone = node.cloneNode(false) as Element;
// Add right-hand siblings to the clone
while (nodeAfterSplit) {
const next = nodeAfterSplit.nextSibling;
clone.appendChild(nodeAfterSplit);
nodeAfterSplit = next;
}
// Maintain li numbering if inside a quote.
if (
node instanceof HTMLOListElement &&
getNearest(node, root, 'BLOCKQUOTE')
) {
(clone as HTMLOListElement).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);
fixCursor(clone);
// Inject clone after original node
parent.insertBefore(clone, node.nextSibling);
// Keep on splitting up the tree
return split(parent, clone, stopNode, root);
};
const _mergeInlines = (
node: Node,
fakeRange: {
startContainer: Node;
startOffset: number;
endContainer: Node;
endOffset: number;
},
): void => {
const children = node.childNodes;
let l = children.length;
const frags: DocumentFragment[] = [];
while (l--) {
const child = children[l];
const prev = l ? children[l - 1] : null;
if (prev && isInline(child) && areAlike(child, prev)) {
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 instanceof Text) {
(prev as Text).appendData(child.data);
} else {
frags.push(empty(child));
}
} else if (child instanceof Element) {
let frag: DocumentFragment | undefined;
while ((frag = frags.pop())) {
child.appendChild(frag);
}
_mergeInlines(child, fakeRange);
}
}
};
const mergeInlines = (node: Node, range: Range): void => {
const element = node instanceof Text ? node.parentNode : node;
if (element instanceof Element) {
const fakeRange = {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset,
};
_mergeInlines(element, fakeRange);
range.setStart(fakeRange.startContainer, fakeRange.startOffset);
range.setEnd(fakeRange.endContainer, fakeRange.endOffset);
}
};
const mergeWithBlock = (
block: Node,
next: Node,
range: Range,
root: Element,
): void => {
let container = next;
let parent: Node | null;
let offset: number;
while (
(parent = container.parentNode) &&
parent !== root &&
parent instanceof Element &&
parent.childNodes.length === 1
) {
container = parent;
}
detach(container);
offset = block.childNodes.length;
// Remove extra <BR> fixer if present.
const 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);
};
const mergeContainers = (node: Node, root: Element): void => {
const prev = node.previousSibling;
const first = node.firstChild;
const isListItem = node.nodeName === 'LI';
// 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) {
const block = createElement('DIV');
block.appendChild(empty(prev));
prev.appendChild(block);
} else {
return;
}
}
detach(node);
const needsFix = !isContainer(node);
prev.appendChild(empty(node));
if (needsFix) {
fixContainer(prev, root);
}
if (first) {
mergeContainers(first, root);
}
} else if (isListItem) {
const block = createElement('DIV');
node.insertBefore(block, first);
fixCursor(block);
}
};
// ---
export {
fixContainer,
fixCursor,
mergeContainers,
mergeInlines,
mergeWithBlock,
split,
};

163
source/node/Node.ts Normal file
View file

@ -0,0 +1,163 @@
import { isLeaf } from './Category';
// ---
const createElement = (
tag: string,
props?: Record<string, string> | null,
children?: Node[],
): HTMLElement => {
const el = document.createElement(tag);
if (props instanceof Array) {
children = props;
props = null;
}
if (props) {
for (const attr in props) {
const value = props[attr];
if (value !== undefined) {
el.setAttribute(attr, value);
}
}
}
if (children) {
children.forEach((node) => el.appendChild(node));
}
return el;
};
// --- Tests
const areAlike = (
node: HTMLElement | Node,
node2: HTMLElement | Node,
): boolean => {
if (isLeaf(node)) {
return false;
}
if (node.nodeType !== node2.nodeType || node.nodeName !== node2.nodeName) {
return false;
}
if (node instanceof HTMLElement && node2 instanceof HTMLElement) {
return (
node.nodeName !== 'A' &&
node.className === node2.className &&
node.style.cssText === node2.style.cssText
);
}
return true;
};
const hasTagAttributes = (
node: Node | Element,
tag: string,
attributes?: Record<string, string> | null,
): boolean => {
if (node.nodeName !== tag) {
return false;
}
for (const attr in attributes) {
if (
!('getAttribute' in node) ||
node.getAttribute(attr) !== attributes[attr]
) {
return false;
}
}
return true;
};
// --- Traversal
const getNearest = (
node: Node | null,
root: Element | DocumentFragment,
tag: string,
attributes?: Record<string, string> | null,
): Node | null => {
while (node && node !== root) {
if (hasTagAttributes(node, tag, attributes)) {
return node;
}
node = node.parentNode;
}
return null;
};
const getNodeBeforeOffset = (node: Node, offset: number): Node => {
let children = node.childNodes;
while (offset && node instanceof Element) {
node = children[offset - 1];
children = node.childNodes;
offset = children.length;
}
return node;
};
const getNodeAfterOffset = (node: Node, offset: number): Node | null => {
let returnNode: Node | null = node;
if (returnNode instanceof Element) {
const children = returnNode.childNodes;
if (offset < children.length) {
returnNode = children[offset];
} else {
while (returnNode && !returnNode.nextSibling) {
returnNode = returnNode.parentNode;
}
if (returnNode) {
returnNode = returnNode.nextSibling;
}
}
}
return returnNode;
};
const getLength = (node: Node): number => {
return node instanceof Element || node instanceof DocumentFragment
? node.childNodes.length
: node instanceof CharacterData
? node.length
: 0;
};
// --- Manipulation
const empty = (node: Node): DocumentFragment => {
const frag = document.createDocumentFragment();
let child = node.firstChild;
while (child) {
frag.appendChild(child);
child = node.firstChild;
}
return frag;
};
const detach = (node: Node): Node => {
const parent = node.parentNode;
if (parent) {
parent.removeChild(node);
}
return node;
};
const replaceWith = (node: Node, node2: Node): void => {
const parent = node.parentNode;
if (parent) {
parent.replaceChild(node2, node);
}
};
// --- Export
export {
areAlike,
createElement,
detach,
empty,
getLength,
getNearest,
getNodeAfterOffset,
getNodeBeforeOffset,
hasTagAttributes,
replaceWith,
};

116
source/node/TreeIterator.ts Normal file
View file

@ -0,0 +1,116 @@
type NODE_TYPE = 1 | 4 | 5;
const SHOW_ELEMENT = 1; // NodeFilter.SHOW_ELEMENT;
const SHOW_TEXT = 4; // NodeFilter.SHOW_TEXT;
const SHOW_ELEMENT_OR_TEXT = 5; // SHOW_ELEMENT|SHOW_TEXT;
const always = (): true => true;
class TreeIterator<T extends Node> {
root: Node;
currentNode: Node;
nodeType: NODE_TYPE;
filter: (n: T) => boolean;
constructor(root: Node, nodeType: NODE_TYPE, filter?: (n: T) => boolean) {
this.root = root;
this.currentNode = root;
this.nodeType = nodeType;
this.filter = filter || always;
}
isAcceptableNode(node: Node): boolean {
const nodeType = node.nodeType;
const nodeFilterType =
nodeType === Node.ELEMENT_NODE
? SHOW_ELEMENT
: nodeType === Node.TEXT_NODE
? SHOW_TEXT
: 0;
return !!(nodeFilterType & this.nodeType) && this.filter(node as T);
}
nextNode(): T | null {
const root = this.root;
let current: Node | null = this.currentNode;
let node: Node | null;
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 (this.isAcceptableNode(node)) {
this.currentNode = node;
return node as T;
}
current = node;
}
}
previousNode(): T | null {
const root = this.root;
let current: Node | null = this.currentNode;
let node: Node | null;
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 (this.isAcceptableNode(node)) {
this.currentNode = node;
return node as T;
}
current = node;
}
}
// Previous node in post-order.
previousPONode(): T | null {
const root = this.root;
let current: Node | null = this.currentNode;
let node: Node | null;
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 (this.isAcceptableNode(node)) {
this.currentNode = node;
return node as T;
}
current = node;
}
}
}
// ---
export { TreeIterator, SHOW_ELEMENT, SHOW_TEXT, SHOW_ELEMENT_OR_TEXT };

69
source/node/Whitespace.ts Normal file
View file

@ -0,0 +1,69 @@
import { ZWS, notWS } from '../Constants';
import { isInline } from './Category';
import { getLength } from './Node';
import { SHOW_ELEMENT_OR_TEXT, SHOW_TEXT, TreeIterator } from './TreeIterator';
// ---
const notWSTextNode = (node: Node): boolean => {
return node instanceof Element
? node.nodeName === 'BR'
: // okay if data is 'undefined' here.
notWS.test((node as CharacterData).data);
};
const isLineBreak = (br: Element, isLBIfEmptyBlock: boolean): boolean => {
let block = br.parentNode!;
while (isInline(block)) {
block = block.parentNode!;
}
const walker = new TreeIterator<Element | Text>(
block,
SHOW_ELEMENT_OR_TEXT,
notWSTextNode,
);
walker.currentNode = br;
return !!walker.nextNode() || (isLBIfEmptyBlock && !walker.previousNode());
};
// --- 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.
const removeZWS = (root: Node, keepNode?: Node): void => {
const walker = new TreeIterator<Text>(root, SHOW_TEXT);
let textNode: Text | null;
let index: number;
while ((textNode = walker.nextNode())) {
while (
(index = textNode.data.indexOf(ZWS)) > -1 &&
// eslint-disable-next-line no-unmodified-loop-condition
(!keepNode || textNode.parentNode !== keepNode)
) {
if (textNode.length === 1) {
let node: Node = textNode;
let parent = node.parentNode;
while (parent) {
parent.removeChild(node);
walker.currentNode = parent;
if (!isInline(parent) || getLength(parent)) {
break;
}
node = parent;
parent = node.parentNode;
}
break;
} else {
textNode.deleteData(index, 1);
}
}
}
};
// ---
export { isLineBreak, removeZWS };

View file

@ -1,22 +0,0 @@
/*jshint ignore:start */
if ( typeof exports === 'object' ) {
module.exports = Squire;
} else if ( typeof define === 'function' && define.amd ) {
define( function () {
return Squire;
});
} else {
win.Squire = Squire;
if ( top !== win &&
doc.documentElement.getAttribute( 'data-squireinit' ) === 'true' ) {
win.editor = new Squire( doc );
if ( win.onEditorLoad ) {
win.onEditorLoad( win.editor );
win.onEditorLoad = null;
}
}
}
}( document ) );

170
source/range/Block.ts Normal file
View file

@ -0,0 +1,170 @@
import { isInline, isBlock } from '../node/Category';
import { getPreviousBlock, getNextBlock } from '../node/Block';
import { getNodeBeforeOffset, getNodeAfterOffset } from '../node/Node';
import { notWS } from '../Constants';
import { isNodeContainedInRange } from './Boundaries';
import { TreeIterator, SHOW_ELEMENT_OR_TEXT } from '../node/TreeIterator';
// ---
// Returns the first block at least partially contained by the range,
// or null if no block is contained by the range.
const getStartBlockOfRange = (
range: Range,
root: Element | DocumentFragment,
): HTMLElement | null => {
const container = range.startContainer;
let block: HTMLElement | null;
// If inline, get the containing block.
if (isInline(container)) {
block = getPreviousBlock(container, root);
} else if (
container !== root &&
container instanceof HTMLElement &&
isBlock(container)
) {
block = container;
} else {
const node = getNodeBeforeOffset(container, range.startOffset);
block = getNextBlock(node, 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.
const getEndBlockOfRange = (
range: Range,
root: Element | DocumentFragment,
): HTMLElement | null => {
const container = range.endContainer;
let block: HTMLElement | null;
// If inline, get the containing block.
if (isInline(container)) {
block = getPreviousBlock(container, root);
} else if (
container !== root &&
container instanceof HTMLElement &&
isBlock(container)
) {
block = container;
} else {
let node = getNodeAfterOffset(container, range.endOffset);
if (!node || !root.contains(node)) {
node = root;
let child: Node | null;
while ((child = node.lastChild)) {
node = child;
}
}
block = getPreviousBlock(node, root);
}
// Check the block actually intersects the range
return block && isNodeContainedInRange(range, block, true) ? block : null;
};
const isContent = (node: Element | Text): boolean => {
return node instanceof Text
? notWS.test(node.data)
: node.nodeName === 'IMG';
};
const rangeDoesStartAtBlockBoundary = (
range: Range,
root: Element,
): boolean => {
const startContainer = range.startContainer;
const startOffset = range.startOffset;
let nodeAfterCursor: Node | null;
// If in the middle or end of a text node, we're not at the boundary.
if (startContainer instanceof Text) {
if (startOffset) {
return false;
}
nodeAfterCursor = startContainer;
} else {
nodeAfterCursor = getNodeAfterOffset(startContainer, startOffset);
if (nodeAfterCursor && !root.contains(nodeAfterCursor)) {
nodeAfterCursor = null;
}
// The cursor was right at the end of the document
if (!nodeAfterCursor) {
nodeAfterCursor = getNodeBeforeOffset(startContainer, startOffset);
if (nodeAfterCursor instanceof Text && nodeAfterCursor.length) {
return false;
}
}
}
// Otherwise, look for any previous content in the same block.
const block = getStartBlockOfRange(range, root);
if (!block) {
return false;
}
const contentWalker = new TreeIterator<Element | Text>(
block,
SHOW_ELEMENT_OR_TEXT,
isContent,
);
contentWalker.currentNode = nodeAfterCursor;
return !contentWalker.previousNode();
};
const rangeDoesEndAtBlockBoundary = (range: Range, root: Element): boolean => {
const endContainer = range.endContainer;
const endOffset = range.endOffset;
let currentNode: Node;
// If in a text node with content, and not at the end, we're not
// at the boundary
if (endContainer instanceof Text) {
const length = endContainer.data.length;
if (length && endOffset < length) {
return false;
}
currentNode = endContainer;
} else {
currentNode = getNodeBeforeOffset(endContainer, endOffset);
}
// Otherwise, look for any further content in the same block.
const block = getEndBlockOfRange(range, root);
if (!block) {
return false;
}
const contentWalker = new TreeIterator<Element | Text>(
block,
SHOW_ELEMENT_OR_TEXT,
isContent,
);
contentWalker.currentNode = currentNode;
return !contentWalker.nextNode();
};
const expandRangeToBlockBoundaries = (range: Range, root: Element): void => {
const start = getStartBlockOfRange(range, root);
const end = getEndBlockOfRange(range, root);
let parent: Node;
if (start && end) {
parent = start.parentNode!;
range.setStart(parent, Array.from(parent.childNodes).indexOf(start));
parent = end.parentNode!;
range.setEnd(parent, Array.from(parent.childNodes).indexOf(end) + 1);
}
};
// ---
export {
getStartBlockOfRange,
getEndBlockOfRange,
rangeDoesStartAtBlockBoundary,
rangeDoesEndAtBlockBoundary,
expandRangeToBlockBoundaries,
};

173
source/range/Boundaries.ts Normal file
View file

@ -0,0 +1,173 @@
import { isLeaf } from '../node/Category';
import { getLength, getNearest } from '../node/Node';
import { isLineBreak } from '../node/Whitespace';
import { TEXT_NODE } from '../Constants';
// ---
const START_TO_START = 0; // Range.START_TO_START
const START_TO_END = 1; // Range.START_TO_END
const END_TO_END = 2; // Range.END_TO_END
const END_TO_START = 3; // Range.END_TO_START
const isNodeContainedInRange = (
range: Range,
node: Node,
partial: boolean,
): boolean => {
const nodeRange = document.createRange();
nodeRange.selectNode(node);
if (partial) {
// Node must not finish before range starts or start after range
// finishes.
const nodeEndBeforeStart =
range.compareBoundaryPoints(END_TO_START, nodeRange) > -1;
const nodeStartAfterEnd =
range.compareBoundaryPoints(START_TO_END, nodeRange) < 1;
return !nodeEndBeforeStart && !nodeStartAfterEnd;
} else {
// Node must start after range starts and finish before range
// finishes
const nodeStartAfterStart =
range.compareBoundaryPoints(START_TO_START, nodeRange) < 1;
const nodeEndBeforeEnd =
range.compareBoundaryPoints(END_TO_END, nodeRange) > -1;
return nodeStartAfterStart && nodeEndBeforeEnd;
}
};
/**
* Moves the range to an equivalent position with the start/end as deep in
* the tree as possible.
*/
const moveRangeBoundariesDownTree = (range: Range): void => {
let { startContainer, startOffset, endContainer, endOffset } = range;
while (!(startContainer instanceof Text)) {
let child = startContainer.childNodes[startOffset];
if (!child || isLeaf(child)) {
if (startOffset) {
child = startContainer.childNodes[startOffset - 1];
if (child instanceof Text) {
startContainer = child;
startOffset = child.data.length;
}
}
break;
}
startContainer = child;
startOffset = 0;
}
if (endOffset) {
while (!(endContainer instanceof Text)) {
const child = endContainer.childNodes[endOffset - 1];
if (!child || isLeaf(child)) {
if (
child &&
child.nodeName === 'BR' &&
!isLineBreak(child as Element, false)
) {
endOffset -= 1;
continue;
}
break;
}
endContainer = child;
endOffset = getLength(endContainer);
}
} else {
while (!(endContainer instanceof Text)) {
const child = endContainer.firstChild!;
if (!child || isLeaf(child)) {
break;
}
endContainer = child;
}
}
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
};
const moveRangeBoundariesUpTree = (
range: Range,
startMax: Node,
endMax: Node,
root: Node,
): void => {
let startContainer = range.startContainer;
let startOffset = range.startOffset;
let endContainer = range.endContainer;
let endOffset = range.endOffset;
let parent: Node;
if (!startMax) {
startMax = range.commonAncestorContainer;
}
if (!endMax) {
endMax = startMax;
}
while (
!startOffset &&
startContainer !== startMax &&
startContainer !== root
) {
parent = startContainer.parentNode!;
startOffset = Array.from(parent.childNodes).indexOf(
startContainer as ChildNode,
);
startContainer = parent;
}
while (true) {
if (endContainer === endMax || endContainer === root) {
break;
}
if (
endContainer.nodeType !== TEXT_NODE &&
endContainer.childNodes[endOffset] &&
endContainer.childNodes[endOffset].nodeName === 'BR' &&
!isLineBreak(endContainer.childNodes[endOffset] as Element, false)
) {
endOffset += 1;
}
if (endOffset !== getLength(endContainer)) {
break;
}
parent = endContainer.parentNode!;
endOffset =
Array.from(parent.childNodes).indexOf(endContainer as ChildNode) +
1;
endContainer = parent;
}
range.setStart(startContainer, startOffset);
range.setEnd(endContainer, endOffset);
};
const moveRangeBoundaryOutOf = (
range: Range,
tag: string,
root: Element,
): Range => {
let parent = getNearest(range.endContainer, root, tag);
if (parent && (parent = parent.parentNode)) {
const clone = range.cloneRange();
moveRangeBoundariesUpTree(clone, parent, parent, root);
if (clone.endContainer === parent) {
range.setStart(clone.endContainer, clone.endOffset);
range.setEnd(clone.endContainer, clone.endOffset);
}
}
return range;
};
// ---
export {
isNodeContainedInRange,
moveRangeBoundariesDownTree,
moveRangeBoundariesUpTree,
moveRangeBoundaryOutOf,
};

View file

@ -0,0 +1,442 @@
import { cleanupBRs } from '../Clean';
import {
split,
fixCursor,
mergeWithBlock,
fixContainer,
mergeContainers,
} from '../node/MergeSplit';
import { detach, getNearest, getLength } from '../node/Node';
import { TreeIterator, SHOW_ELEMENT_OR_TEXT } from '../node/TreeIterator';
import { isInline, isContainer, isLeaf } from '../node/Category';
import { getNextBlock, isEmptyBlock, getPreviousBlock } from '../node/Block';
import {
getStartBlockOfRange,
getEndBlockOfRange,
rangeDoesEndAtBlockBoundary,
rangeDoesStartAtBlockBoundary,
} from './Block';
import {
moveRangeBoundariesDownTree,
moveRangeBoundariesUpTree,
} from './Boundaries';
// ---
function createRange(startContainer: Node, startOffset: number): Range;
function createRange(
startContainer: Node,
startOffset: number,
endContainer: Node,
endOffset: number,
): Range;
function createRange(
startContainer: Node,
startOffset: number,
endContainer?: Node,
endOffset?: number,
): Range {
const range = document.createRange();
range.setStart(startContainer, startOffset);
if (endContainer && typeof endOffset === 'number') {
range.setEnd(endContainer, endOffset);
} else {
range.setEnd(startContainer, startOffset);
}
return range;
}
const insertNodeInRange = (range: Range, node: Node): void => {
// Insert at start.
let { startContainer, startOffset, endContainer, endOffset } = range;
let children: NodeListOf<ChildNode>;
// If part way through a text node, split it.
if (startContainer instanceof Text) {
const parent = startContainer.parentNode!;
children = parent.childNodes;
if (startOffset === startContainer.length) {
startOffset = Array.from(children).indexOf(startContainer) + 1;
if (range.collapsed) {
endContainer = parent;
endOffset = startOffset;
}
} else {
if (startOffset) {
const afterSplit = startContainer.splitText(startOffset);
if (endContainer === startContainer) {
endOffset -= startOffset;
endContainer = afterSplit;
} else if (endContainer === parent) {
endOffset += 1;
}
startContainer = afterSplit;
}
startOffset = Array.from(children).indexOf(
startContainer as ChildNode,
);
}
startContainer = parent;
} else {
children = startContainer.childNodes;
}
const 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);
};
/**
* Removes the contents of the range and returns it as a DocumentFragment.
* The range at the end will be at the same position, with the edges just
* before/after the split. If the start/end have the same parents, it will
* be collapsed.
*/
const extractContentsOfRange = (
range: Range,
common: Node | null,
root: Element,
): DocumentFragment => {
const frag = document.createDocumentFragment();
if (range.collapsed) {
return frag;
}
if (!common) {
common = range.commonAncestorContainer;
}
if (common instanceof Text) {
common = common.parentNode!;
}
const startContainer = range.startContainer;
const startOffset = range.startOffset;
let endContainer = split(range.endContainer, range.endOffset, common, root);
let endOffset = 0;
let node = split(startContainer, startOffset, common, root);
while (node && node !== endContainer) {
const next = node.nextSibling;
frag.appendChild(node);
node = next;
}
// Merge text nodes if adjacent
if (startContainer instanceof Text && endContainer instanceof Text) {
startContainer.appendData(endContainer.data);
detach(endContainer);
endContainer = startContainer;
endOffset = startOffset;
}
range.setStart(startContainer, startOffset);
if (endContainer) {
range.setEnd(endContainer, endOffset);
} else {
// endContainer will be null if at end of parent's child nodes list.
range.setEnd(common, common.childNodes.length);
}
fixCursor(common);
return frag;
};
/**
* Returns the next/prev node that's part of the same inline content.
*/
const getAdjacentInlineNode = (
iterator: TreeIterator<Node>,
method: 'nextNode' | 'previousPONode',
node: Node,
): Node | null => {
iterator.currentNode = node;
let nextNode: Node | null;
while ((nextNode = iterator[method]())) {
if (nextNode instanceof Text || isLeaf(nextNode)) {
return nextNode;
}
if (!isInline(nextNode)) {
return null;
}
}
return null;
};
const deleteContentsOfRange = (
range: Range,
root: Element,
): DocumentFragment => {
const startBlock = getStartBlockOfRange(range, root);
let endBlock = getEndBlockOfRange(range, root);
const needsMerge = startBlock !== endBlock;
// Move boundaries up as much as possible without exiting block,
// to reduce need to split.
if (startBlock && endBlock) {
moveRangeBoundariesDownTree(range);
moveRangeBoundariesUpTree(range, startBlock, endBlock, root);
}
// Remove selected range
const 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);
}
// Ensure root has a block-level element in it.
const child = root.firstChild;
if (!child || child.nodeName === 'BR') {
fixCursor(root);
if (root.firstChild) {
range.selectNodeContents(root.firstChild);
}
}
range.collapse(true);
// Now we may need to swap a space for a nbsp if the browser is going
// to swallow it due to HTML whitespace rules:
const startContainer = range.startContainer;
const startOffset = range.startOffset;
const iterator = new TreeIterator(root, SHOW_ELEMENT_OR_TEXT);
// Find the character after cursor point
let afterNode: Node | null = startContainer;
let afterOffset = startOffset;
if (!(afterNode instanceof Text) || afterOffset === afterNode.data.length) {
afterNode = getAdjacentInlineNode(iterator, 'nextNode', afterNode);
afterOffset = 0;
}
// Find the character before cursor point
let beforeNode: Node | null = startContainer;
let beforeOffset = startOffset - 1;
if (!(beforeNode instanceof Text) || beforeOffset === -1) {
beforeNode = getAdjacentInlineNode(
iterator,
'previousPONode',
afterNode ||
(startContainer instanceof Text
? startContainer
: startContainer.childNodes[startOffset] || startContainer),
);
if (beforeNode instanceof Text) {
beforeOffset = beforeNode.data.length;
}
}
// If range starts at block boundary and character after cursor point
// is a space, replace with nbsp
let node = null;
let offset = 0;
if (
afterNode instanceof Text &&
afterNode.data.charAt(afterOffset) === ' ' &&
rangeDoesStartAtBlockBoundary(range, root)
) {
node = afterNode;
offset = afterOffset;
} else if (
beforeNode instanceof Text &&
beforeNode.data.charAt(beforeOffset) === ' '
) {
// If character before cursor point is a space, replace with nbsp
// if either:
// a) There is a space after it; or
// b) The point after is the end of the block
if (
(afterNode instanceof Text &&
afterNode.data.charAt(afterOffset) === ' ') ||
rangeDoesEndAtBlockBoundary(range, root)
) {
node = beforeNode;
offset = beforeOffset;
}
}
if (node) {
node.replaceData(offset, 1, ' '); // nbsp
}
// Range needs to be put back in place
range.setStart(startContainer, startOffset);
range.collapse(true);
return frag;
};
// Contents of range will be deleted.
// After method, range will be around inserted content
const insertTreeFragmentIntoRange = (
range: Range,
frag: DocumentFragment,
root: Element,
): void => {
const firstInFragIsInline = frag.firstChild && isInline(frag.firstChild);
let node: Node | null;
// Fixup content: ensure no top-level inline, and add cursor fix elements.
fixContainer(frag, root);
node = frag;
while ((node = getNextBlock(node, root))) {
fixCursor(node);
}
// 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.
const 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. <blockquote><p>Foo</p></blockquote>
// This seems a reasonable approximation of user intent.
let block = getStartBlockOfRange(range, root);
let blockContentsAfterSplit: DocumentFragment | null = null;
const firstBlockInFrag = getNextBlock(frag, frag);
const 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
let container = range.endContainer;
let offset = range.endOffset;
// Remove trailing <br> we don't want this considered content to be
// inserted again later
cleanupBRs(block as HTMLElement, root, false);
if (isInline(container)) {
// Split up to block parent.
const nodeAfterSplit = split(
container,
offset,
getPreviousBlock(container, root) || root,
root,
) as Node;
container = nodeAfterSplit.parentNode!;
offset = Array.from(container.childNodes).indexOf(
nodeAfterSplit as ChildNode,
);
}
if (/*isBlock( container ) && */ offset !== getLength(container)) {
// Collect any inline contents of the block after the range point
blockContentsAfterSplit = document.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 =
Array.from(container.parentNode!.childNodes).indexOf(
container as ChildNode,
) + 1;
container = container.parentNode!;
range.setEnd(container, offset);
}
// Is there still any content in the fragment?
if (getLength(frag)) {
if (replaceBlock && block) {
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
let nodeAfterSplit = split(
range.endContainer,
range.endOffset,
stopPoint,
root,
) as Node | null;
const 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);
const container = range.endContainer;
const 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 && block) {
const tempRange = range.cloneRange();
mergeWithBlock(block, blockContentsAfterSplit, tempRange, root);
range.setEnd(tempRange.endContainer, tempRange.endOffset);
}
moveRangeBoundariesDownTree(range);
};
// ---
export {
createRange,
deleteContentsOfRange,
extractContentsOfRange,
insertNodeInRange,
insertTreeFragmentIntoRange,
};

View file

View file

@ -1,23 +0,0 @@
<html>
<head>
<meta charset="utf-8">
<title>Squire rich-text editor tests</title>
<link rel="stylesheet" href="../node_modules/mocha/mocha.css" />
</head>
<body>
<script src="../node_modules/mocha/mocha.js"></script>
<script src="../node_modules/unexpected/unexpected.js"></script>
<script src="../build/squire-raw.js"></script>
<iframe id="testFrame" style="visibility: hidden;"></iframe>
<div id="mocha"></div>
<script>
mocha.setup('bdd');
var expect = weknowhow.expect;
</script>
<script src="squire.spec.js"></script>
<script>
mocha.run();
</script>
</body>
</html>

View file

@ -1,430 +0,0 @@
/*global expect, describe, afterEach, beforeEach, it */
expect = expect.clone()
.addType({
name: 'SquireRTE',
base: 'object',
identify: function (value) {
return value instanceof Squire;
},
inspect: function (value) {
return 'Squire RTE: ' + value.getHTML();
}
})
.addAssertion('[not] to contain HTML', function (expect, editor, expectedValue) {
this.errorMode = 'bubble';
var actualHTML = editor.getHTML().replace(/<br>/g, '');
// BR tags are inconsistent across browsers. Removing them allows cross-browser testing.
expect(actualHTML, '[not] to be', expectedValue);
});
describe('Squire RTE', function () {
var doc, editor;
beforeEach(function () {
var iframe = document.getElementById('testFrame');
doc = iframe.contentDocument;
editor = new Squire(doc);
});
function selectAll(editor) {
doc.getSelection().removeAllRanges()
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0), 0);
range.setEnd(doc.body.childNodes.item(0), doc.body.childNodes.item(0).childNodes.length);
editor.setSelection(range);
}
describe('hasFormat', function () {
var startHTML;
beforeEach( function () {
startHTML = '<div>one <b>two three</b> four <i>five</i></div>';
editor.setHTML(startHTML);
});
it('returns false when range not touching format', function () {
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0), 0);
range.setEnd(doc.body.childNodes.item(0), 1);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be false');
});
it('returns false when range inside other format', function () {
var range = doc.createRange();
range.setStart(doc.querySelector('i').childNodes[0], 1);
range.setEnd(doc.querySelector('i').childNodes[0], 2);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be false');
});
it('returns false when range covers anything outside format', function () {
var range = doc.createRange();
range.setStart(doc.querySelector('b').previousSibling, 2);
range.setEnd(doc.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be false');
});
it('returns true when range inside format', function () {
var range = doc.createRange();
range.setStart(doc.querySelector('b').childNodes[0], 2);
range.setEnd(doc.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
it('returns true when range covers start of format', function () {
var range = doc.createRange();
range.setStartBefore(doc.querySelector('b'));
range.setEnd(doc.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
it('returns true when range covers start of format, even in weird cases', function () {
var range = doc.createRange();
var prev = doc.querySelector('b').previousSibling;
range.setStart(prev, prev.length);
range.setEnd(doc.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
it('returns true when range covers end of format', function () {
var range = doc.createRange();
range.setStart(doc.querySelector('b').childNodes[0], 2);
range.setEndAfter(doc.querySelector('b'));
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
it('returns true when range covers end of format, even in weird cases', function () {
var range = doc.createRange();
range.setStart(doc.querySelector('b').childNodes[0], 2);
var next = doc.querySelector('b').nextSibling;
range.setEnd(next, 0);
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
it('returns true when range covers all of format', function () {
var range = doc.createRange();
range.setStartBefore(doc.querySelector('b'));
range.setEndAfter(doc.querySelector('b'));
editor.setSelection(range);
expect(editor.hasFormat('b'), 'to be true');
});
});
describe('removeAllFormatting', function () {
// Trivial cases
it('removes inline styles', function () {
var startHTML = '<div><i>one</i> <b>two</b> <u>three</u> <sub>four</sub> <sup>five</sup></div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
selectAll(editor);
editor.removeAllFormatting();
expect(editor, 'to contain HTML', '<div>one two three four five</div>');
});
it('removes block styles', function () {
var startHTML = '<div><blockquote>one</blockquote><ul><li>two</li></ul>' +
'<ol><li>three</li></ol><table><tbody><tr><th>four</th><td>five</td></tr></tbody></table></div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
selectAll(editor);
editor.removeAllFormatting();
var expectedHTML = '<div>one</div><div>two</div><div>three</div><div>four</div><div>five</div>';
expect(editor, 'to contain HTML', expectedHTML);
});
// Potential bugs
it('removes styles that begin inside the range', function () {
var startHTML = '<div>one <i>two three four five</i></div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0), 0);
range.setEnd(doc.getElementsByTagName('i').item(0).childNodes.item(0), 4);
editor.removeAllFormatting(range);
expect(editor, 'to contain HTML', '<div>one two <i>three four five</i></div>');
});
it('removes styles that end inside the range', function () {
var startHTML = '<div><i>one two three four</i> five</div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
range.setStart(doc.getElementsByTagName('i').item(0).childNodes.item(0), 13);
range.setEnd(doc.body.childNodes.item(0), doc.body.childNodes.item(0).childNodes.length);
editor.removeAllFormatting(range);
expect(editor, 'to contain HTML', '<div><i>one two three</i> four five</div>');
});
it('removes styles enclosed by the range', function () {
var startHTML = '<div>one <i>two three four</i> five</div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0), 0);
range.setEnd(doc.body.childNodes.item(0), doc.body.childNodes.item(0).childNodes.length);
editor.removeAllFormatting(range);
expect(editor, 'to contain HTML', '<div>one two three four five</div>');
});
it('removes styles enclosing the range', function () {
var startHTML = '<div><i>one two three four five</i></div>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
range.setStart(doc.getElementsByTagName('i').item(0).childNodes.item(0), 4);
range.setEnd(doc.getElementsByTagName('i').item(0).childNodes.item(0), 18);
editor.removeAllFormatting(range);
expect(editor, 'to contain HTML', '<div><i>one </i>two three four<i> five</i></div>');
});
it('removes nested styles and closes tags correctly', function () {
var startHTML = '<table><tbody><tr><td>one</td></tr><tr><td>two</td><td>three</td></tr><tr><td>four</td><td>five</td></tr></tbody></table>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
range.setStart(doc.getElementsByTagName('td').item(1), 0);
range.setEnd(doc.getElementsByTagName('td').item(2), doc.getElementsByTagName('td').item(2).childNodes.length);
editor.removeAllFormatting(range);
expect(editor, 'to contain HTML', '<table><tbody><tr><td>one</td></tr></tbody></table>' +
'<div>two</div>' +
'<div>three</div>' +
'<table><tbody><tr><td>four</td><td>five</td></tr></tbody></table>');
});
});
describe('getPath', function () {
var startHTML;
beforeEach( function () {
startHTML = '<div>one <b>two three</b> four <i>five</i></div>';
editor.setHTML(startHTML);
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0), 0);
range.setEnd(doc.body.childNodes.item(0), 1);
editor.setSelection(range);
});
it('returns the path to the selection', function () {
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0).childNodes.item(1), 0);
range.setEnd(doc.body.childNodes.item(0).childNodes.item(1), 0);
editor.setSelection(range);
//Manually tell it to update the path
editor._updatePath(range);
expect(editor.getPath(), 'to be', 'DIV>B');
});
it('includes id in the path', function () {
editor.insertHTML('<div id="spanId">Text</div>');
expect(editor.getPath(), 'to be', 'DIV#spanId');
});
it('includes class name in the path', function () {
editor.insertHTML('<div class="myClass">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.myClass');
});
it('includes all class names in the path', function () {
editor.insertHTML('<div class="myClass myClass2 myClass3">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.myClass.myClass2.myClass3');
});
it('includes direction in the path', function () {
editor.insertHTML('<div dir="rtl">Text</div>');
expect(editor.getPath(), 'to be', 'DIV[dir=rtl]');
});
it('includes highlight value in the path', function () {
editor.insertHTML('<div class="highlight" style="background-color: rgb(255, 0, 0)">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.highlight[backgroundColor=rgb(255,0,0)]');
});
it('includes color value in the path', function () {
editor.insertHTML('<div class="colour" style="color: rgb(255, 0, 0)">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.colour[color=rgb(255,0,0)]');
});
it('includes font family value in the path', function () {
editor.insertHTML('<div class="font" style="font-family: Arial, sans-serif">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.font[fontFamily=Arial,sans-serif]');
});
it('includes font size value in the path', function () {
editor.insertHTML('<div class="size" style="font-size: 12pt">Text</div>');
expect(editor.getPath(), 'to be', 'DIV.size[fontSize=12pt]');
});
it('is (selection) when the selection is a range', function() {
var range = doc.createRange();
range.setStart(doc.body.childNodes.item(0).childNodes.item(0), 0);
range.setEnd(doc.body.childNodes.item(0).childNodes.item(3), 0);
editor.setSelection(range);
//Manually tell it to update the path
editor._updatePath(range);
expect(editor.getPath(), 'to be', '(selection)');
});
});
describe('multi-level lists', function () {
it('increases list indentation', function() {
var startHTML = '<ul><li><div>a</div></li><li><div>b</div></li><li><div>c</div></li></ul>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
var textNode = doc.getElementsByTagName('li').item(1).childNodes[0].childNodes[0]
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.increaseListLevel()
expect(editor, 'to contain HTML', '<ul><li><div>a</div></li><ul><li><div>b</div></li></ul><li><div>c</div></li></ul>');
});
it('increases list indentation 2', function() {
var startHTML = '<ul><li><div>a</div></li><li><div>b</div></li><li><div>c</div></li></ul>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
var textNode = doc.getElementsByTagName('li').item(1).childNodes[0].childNodes[0]
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.increaseListLevel()
editor.increaseListLevel()
expect(editor, 'to contain HTML', '<ul><li><div>a</div></li><ul><ul><li><div>b</div></li></ul></ul><li><div>c</div></li></ul>');
});
it('decreases list indentation', function() {
var startHTML = '<ul><li><div>a</div></li><ul><li><div>b</div></li></ul><li><div>c</div></li></ul>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
var textNode = doc.getElementsByTagName('li').item(1).childNodes[0].childNodes[0]
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.decreaseListLevel()
expect(editor, 'to contain HTML', '<ul><li><div>a</div></li><li><div>b</div></li><li><div>c</div></li></ul>');
});
it('decreases list indentation 2', function() {
var startHTML = '<ul><li><div>a</div></li><ul><ul><li><div>b</div></li></ul></ul><li><div>c</div></li></ul>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
var textNode = doc.getElementsByTagName('li').item(1).childNodes[0].childNodes[0]
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.decreaseListLevel()
editor.decreaseListLevel()
expect(editor, 'to contain HTML', '<ul><li><div>a</div></li><li><div>b</div></li><li><div>c</div></li></ul>');
});
it('removes lists', function() {
var startHTML = '<ul><li><div>foo</div></li><ul><li><div>bar</div></li></ul></ul>';
editor.setHTML(startHTML);
expect(editor, 'to contain HTML', startHTML);
var range = doc.createRange();
var textNode = doc.getElementsByTagName('li').item(1).childNodes[0].childNodes[0]
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.removeList()
expect(editor, 'to contain HTML', '<ul><li><div>foo</div></li></ul><div>bar</div>');
})
});
describe('insertHTML', function() {
it('fix CF_HTML incomplete table', function() {
editor.insertHTML('<table><tbody><tr><!--StartFragment--><td>text</td><!--EndFragment--></tr></tbody></table>');
expect(editor.getHTML(), 'to contain', '<table><tbody><tr><td>text<br></td></tr></tbody></table>');
editor.setHTML('');
editor.insertHTML('<table><tbody><!--StartFragment--><tr><td>text1</td><td>text2</td></tr><!--EndFragment--></tbody></table>');
expect(editor.getHTML(), 'to contain', '<table><tbody><tr><td>text1<br></td><td>text2<br></td></tr></tbody></table>');
});
var LINK_MAP = {
"dewdw@fre.fr": "mailto:dewdw@fre.fr",
"dew@free.fr?dew=dew": "mailto:dew@free.fr?dew=dew",
"dew@free.fr?subject=dew": "mailto:dew@free.fr?subject=dew",
"test@example.com?subject=foo&body=bar": "mailto:test@example.com?subject=foo&body=bar",
"dew@fre.fr dewdwe @dew": "mailto:dew@fre.fr",
"http://free.fr": "http://free.fr",
"http://google.com": "http://google.com",
"https://google.com": "https://google.com",
"https://www.google.com": "https://www.google.com",
"https://www.google.com/": "https://www.google.com/",
"https://google.com/?": "https://google.com/",
"https://google.com?": "https://google.com",
"https://google.com?a": "https://google.com/?a",
"https://google.com?a=": "https://google.com/?a=",
"https://google.com?a=b": "https://google.com/?a=b",
"https://google.com?a=b?": "https://google.com/?a=b",
"https://google.com?a=b&": "https://google.com/?a=b&",
"https://google.com?a=b&c": "https://google.com/?a=b&c",
"https://google.com?a=b&c=": "https://google.com/?a=b&c=",
"https://google.com?a=b&c=d": "https://google.com/?a=b&c=",
"https://google.com?a=b&c=d?": "https://google.com/?a=b&c=d",
"https://google.com?a=b&c=d&": "https://google.com/?a=b&c=d&",
"https://google.com?a=b&c=d&e=": "https://google.com/?a=b&c=d&e=",
"https://google.com?a=b&c=d&e=f": "https://google.com/?a=b&c=d&e=f"
};
Object.keys(LINK_MAP).forEach((input) => {
it('should auto convert links to anchor: ' + input, function() {
editor.insertHTML(input);
var link = editor.getDocument().querySelector('a');
expect(link.href, 'to contain', LINK_MAP[input]);
editor.setHTML('');
});
});
it('should auto convert a part of the link to an anchor', function() {
editor.insertHTML(`
dew@fre.fr dewdwe @dew
`);
var link = editor.getDocument().querySelector('a');
expect(link.textContent, 'to be', 'dew@fre.fr');
expect(link.href, 'to be', 'mailto:dew@fre.fr');
editor.setHTML('');
});
it('should not auto convert non links to anchor', function() {
editor.insertHTML(`
dewdwe @dew
deww.de
monique.fre
google.com
`);
var link = editor.getDocument().querySelector('a');
expect(link, 'to be', null);
editor.setHTML('');
});
});
afterEach(function () {
editor = null;
var iframe = document.getElementById('testFrame');
iframe.src = 'blank.html';
});
});

530
test/squire.spec.ts Normal file
View file

@ -0,0 +1,530 @@
/**
* @jest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
import { Squire } from '../source/Editor';
document.body.innerHTML = `<div id="squire">`;
let editor;
describe('Squire RTE', () => {
beforeEach(() => {
const squireContainer = document.getElementById('squire')!;
editor = new Squire(squireContainer, {
sanitizeToDOMFragment(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const frag = doc.createDocumentFragment();
const body = doc.body;
while (body.firstChild) {
frag.appendChild(body.firstChild);
}
return document.importNode(frag, true);
},
});
});
function selectAll(editor) {
document.getSelection().removeAllRanges();
const range = document.createRange();
range.setStart(editor._root.childNodes.item(0), 0);
range.setEnd(
editor._root.childNodes.item(0),
editor._root.childNodes.item(0).childNodes.length,
);
editor.setSelection(range);
}
describe('hasFormat', () => {
let startHTML;
beforeEach(() => {
startHTML = '<div>one <b>two three</b> four <i>five</i></div>';
editor.setHTML(startHTML);
});
it('returns false when range not touching format', () => {
const range = document.createRange();
range.setStart(editor._root.childNodes.item(0), 0);
range.setEnd(editor._root.childNodes.item(0), 1);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(false);
});
it('returns false when range inside other format', () => {
const range = document.createRange();
range.setStart(document.querySelector('i').childNodes[0], 1);
range.setEnd(document.querySelector('i').childNodes[0], 2);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(false);
});
it('returns false when range covers anything outside format', () => {
const range = document.createRange();
range.setStart(document.querySelector('b').previousSibling, 2);
range.setEnd(document.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(false);
});
it('returns true when range inside format', () => {
const range = document.createRange();
range.setStart(document.querySelector('b').childNodes[0], 2);
range.setEnd(document.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
it('returns true when range covers start of format', () => {
const range = document.createRange();
range.setStartBefore(document.querySelector('b'));
range.setEnd(document.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
it('returns true when range covers start of format, even in weird cases', () => {
const range = document.createRange();
const prev = document.querySelector('b').previousSibling as Text;
range.setStart(prev, prev.length);
range.setEnd(document.querySelector('b').childNodes[0], 8);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
it('returns true when range covers end of format', () => {
const range = document.createRange();
range.setStart(document.querySelector('b').childNodes[0], 2);
range.setEndAfter(document.querySelector('b'));
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
it('returns true when range covers end of format, even in weird cases', () => {
const range = document.createRange();
range.setStart(document.querySelector('b').childNodes[0], 2);
const next = document.querySelector('b').nextSibling;
range.setEnd(next, 0);
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
it('returns true when range covers all of format', () => {
const range = document.createRange();
range.setStartBefore(document.querySelector('b'));
range.setEndAfter(document.querySelector('b'));
editor.setSelection(range);
expect(editor.hasFormat('b')).toBe(true);
});
});
describe('removeAllFormatting', () => {
// Trivial cases
it('removes inline styles', () => {
const startHTML =
'<div><i>one</i> <b>two</b> <u>three</u> <sub>four</sub> <sup>five</sup><br></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
selectAll(editor);
editor.removeAllFormatting();
expect(editor._root.innerHTML).toBe(
'<div>one two three four five<br></div>',
);
});
it('removes block styles', () => {
const startHTML =
'<div><blockquote>one<br></blockquote><ul><li>two<br></li></ul><ol><li>three<br></li></ol><table><tbody><tr><th>four<br></th><td>five<br></td></tr></tbody></table></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
selectAll(editor);
editor.removeAllFormatting();
const expectedHTML =
'<div>one<br></div><div>two<br></div><div>three<br></div><div>four<br></div><div>five<br></div>';
expect(editor._root.innerHTML).toBe(expectedHTML);
});
// Potential bugs
// TODO: more analysis of this; this could just be an off-by-one in the test
it('removes styles that begin inside the range', () => {
const startHTML = '<div><i>one two three four</i> five<br></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
range.setStart(
editor._root
.getElementsByTagName('i')
.item(0)
.childNodes.item(0),
3,
);
range.setEnd(
editor._root
.getElementsByTagName('i')
.item(0)
.childNodes.item(0),
8,
);
editor.removeAllFormatting(range);
expect(editor._root.innerHTML).toBe(
'<div><i>one</i> two <i>three four</i> five<br></div>',
);
});
it('removes styles that end inside the range', () => {
const startHTML = '<div><i>one two three four</i> five<br></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
range.setStart(
document.getElementsByTagName('i').item(0).childNodes.item(0),
13,
);
range.setEnd(
editor._root.childNodes.item(0),
editor._root.childNodes.item(0).childNodes.length,
);
editor.removeAllFormatting(range);
expect(editor._root.innerHTML).toBe(
'<div><i>one two three</i> four five<br></div>',
);
});
it('removes styles enclosed by the range', () => {
const startHTML = '<div>one <i>two three four</i> five<br></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
range.setStart(editor._root.childNodes.item(0), 0);
range.setEnd(
editor._root.childNodes.item(0),
editor._root.childNodes.item(0).childNodes.length,
);
editor.removeAllFormatting(range);
expect(editor._root.innerHTML).toBe(
'<div>one two three four five<br></div>',
);
});
it('removes styles enclosing the range', () => {
const startHTML = '<div><i>one two three four five</i><br></div>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
range.setStart(
document.getElementsByTagName('i').item(0).childNodes.item(0),
4,
);
range.setEnd(
document.getElementsByTagName('i').item(0).childNodes.item(0),
18,
);
editor.removeAllFormatting(range);
expect(editor._root.innerHTML).toBe(
'<div><i>one </i>two three four<i> five</i><br></div>',
);
});
it('removes nested styles and closes tags correctly', () => {
const startHTML =
'<table><tbody><tr><td>one<br></td></tr><tr><td>two<br></td><td>three<br></td></tr><tr><td>four<br></td><td>five<br></td></tr></tbody></table>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
range.setStart(document.getElementsByTagName('td').item(1), 0);
range.setEnd(
document.getElementsByTagName('td').item(2),
document.getElementsByTagName('td').item(2).childNodes.length,
);
editor.removeAllFormatting(range);
expect(editor._root.innerHTML).toBe(
'<table><tbody><tr><td>one<br></td></tr></tbody></table><div>two<br></div><div>three<br></div><table><tbody><tr><td>four<br></td><td>five<br></td></tr></tbody></table>',
);
});
});
describe('getPath', () => {
let startHTML;
beforeEach(() => {
startHTML = '<div>one <b>two three</b> four <i>five</i></div>';
editor.setHTML(startHTML);
const range = document.createRange();
range.setStart(editor._root.childNodes.item(0), 0);
range.setEnd(editor._root.childNodes.item(0), 1);
editor.setSelection(range);
});
it('returns the path to the selection', () => {
const range = document.createRange();
range.setStart(
editor._root.childNodes.item(0).childNodes.item(1),
0,
);
range.setEnd(editor._root.childNodes.item(0).childNodes.item(1), 0);
editor.setSelection(range);
//Manually tell it to update the path
editor._updatePath(range);
expect(editor.getPath()).toBe('DIV>B');
});
it('includes id in the path', () => {
editor.setHTML('<div id="spanId">Text</div>');
expect(editor.getPath()).toBe('DIV#spanId');
});
it('includes class name in the path', () => {
editor.setHTML('<div class="myClass">Text</div>');
expect(editor.getPath()).toBe('DIV.myClass');
});
it('includes all class names in the path', () => {
editor.setHTML('<div class="myClass myClass2 myClass3">Text</div>');
expect(editor.getPath()).toBe('DIV.myClass.myClass2.myClass3');
});
it('includes direction in the path', () => {
editor.setHTML('<div dir="rtl">Text</div>');
expect(editor.getPath()).toBe('DIV[dir=rtl]');
});
it('includes highlight value in the path', () => {
editor.setHTML(
'<div class="highlight" style="background-color: rgb(255, 0, 0)">Text</div>',
);
expect(editor.getPath()).toBe(
'DIV.highlight[backgroundColor=rgb(255,0,0)]',
);
});
it('includes color value in the path', () => {
editor.setHTML(
'<div class="color" style="color: rgb(255, 0, 0)">Text</div>',
);
expect(editor.getPath()).toBe('DIV.color[color=rgb(255,0,0)]');
});
it('includes font family value in the path', () => {
editor.setHTML(
'<div class="font" style="font-family: Arial, sans-serif">Text</div>',
);
expect(editor.getPath()).toBe(
'DIV.font[fontFamily=Arial,sans-serif]',
);
});
it('includes font size value in the path', () => {
editor.setHTML(
'<div class="size" style="font-size: 12pt">Text</div>',
);
expect(editor.getPath()).toBe('DIV.size[fontSize=12pt]');
});
it('is (selection) when the selection is a range', () => {
const range = document.createRange();
range.setStart(
editor._root.childNodes.item(0).childNodes.item(0) as Node,
0,
);
range.setEnd(
editor._root.childNodes.item(0).childNodes.item(3) as Node,
0,
);
editor.setSelection(range);
//Manually tell it to update the path
editor._updatePath(range);
expect(editor.getPath()).toBe('(selection)');
});
});
describe('multi-level lists', () => {
it('increases list indentation', () => {
const startHTML =
'<ul><li><div>a<br></div></li><li><div>b<br></div></li><li><div>c<br></div></li></ul>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
const textNode = document.getElementsByTagName('li').item(1)
.childNodes[0].childNodes[0];
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.increaseListLevel();
expect(editor._root.innerHTML).toBe(
'<ul><li><div>a<br></div></li><ul><li><div>b<br></div></li></ul><li><div>c<br></div></li></ul>',
);
});
it('increases list indentation 2', () => {
const startHTML =
'<ul><li><div>a<br></div></li><li><div>b<br></div></li><li><div>c<br></div></li></ul>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
const textNode = document.getElementsByTagName('li').item(1)
.childNodes[0].childNodes[0];
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.increaseListLevel();
editor.increaseListLevel();
expect(editor._root.innerHTML).toBe(
'<ul><li><div>a<br></div></li><ul><li><div>b<br></div></li></ul><li><div>c<br></div></li></ul>',
);
});
it('decreases list indentation', () => {
const startHTML =
'<ul><li><div>a<br></div></li><ul><li><div>b<br></div></li></ul><li><div>c<br></div></li></ul>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
const textNode = document.getElementsByTagName('li').item(1)
.childNodes[0].childNodes[0];
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.decreaseListLevel();
expect(editor._root.innerHTML).toBe(
'<ul><li><div>a<br></div></li><li><div>b<br></div></li><li><div>c<br></div></li></ul>',
);
});
it('decreases list indentation 2', () => {
const startHTML =
'<ul><li><div>a<br></div></li><ul><ul><li><div>b<br></div></li></ul></ul><li><div>c<br></div></li></ul>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
const textNode = document.getElementsByTagName('li').item(1)
.childNodes[0].childNodes[0];
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.decreaseListLevel();
editor.decreaseListLevel();
expect(editor._root.innerHTML).toBe(
'<ul><li><div>a<br></div></li><li><div>b<br></div></li><li><div>c<br></div></li></ul>',
);
});
it('removes lists', () => {
const startHTML =
'<ul><li><div>foo<br></div></li><ul><li><div>bar<br></div></li></ul></ul>';
editor.setHTML(startHTML);
expect(editor._root.innerHTML).toBe(startHTML);
const range = document.createRange();
const textNode = document.getElementsByTagName('li').item(1)
.childNodes[0].childNodes[0];
range.setStart(textNode, 0);
range.setEnd(textNode, 0);
editor.setSelection(range);
editor.removeList();
expect(editor._root.innerHTML).toBe(
'<ul><li><div>foo<br></div></li></ul><div>bar<br></div>',
);
});
});
describe('insertHTML', () => {
it('fix CF_HTML incomplete table', () => {
editor.insertHTML(
'<table><tbody><tr><!--StartFragment--><td>text</td><!--EndFragment--></tr></tbody></table>',
);
expect(editor.getHTML()).toEqual(
expect.stringMatching(
'<table><tbody><tr><td>text<br></td></tr></tbody></table>',
),
);
editor.setHTML('');
editor.insertHTML(
'<table><tbody><!--StartFragment--><tr><td>text1</td><td>text2</td></tr><!--EndFragment--></tbody></table>',
);
expect(editor.getHTML()).toEqual(
expect.stringMatching(
'<table><tbody><tr><td>text1<br></td><td>text2<br></td></tr></tbody></table>',
),
);
});
const LINK_MAP = {
'dewdw@fre.fr': 'mailto:dewdw@fre.fr',
'dew@free.fr?dew=dew': 'mailto:dew@free.fr?dew=dew',
'dew@free.fr?subject=dew': 'mailto:dew@free.fr?subject=dew',
'test@example.com?subject=foo&body=bar':
'mailto:test@example.com?subject=foo&body=bar',
'dew@fre.fr dewdwe @dew': 'mailto:dew@fre.fr',
'http://free.fr': 'http://free.fr/',
'http://google.com': 'http://google.com/',
'https://google.com': 'https://google.com/',
'https://www.google.com': 'https://www.google.com/',
'https://www.google.com/': 'https://www.google.com/',
'https://google.com/?': 'https://google.com/',
'https://google.com?': 'https://google.com/',
'https://google.com?a': 'https://google.com/?a',
'https://google.com?a=': 'https://google.com/?a=',
'https://google.com?a=b': 'https://google.com/?a=b',
'https://google.com?a=b?': 'https://google.com/?a=b',
'https://google.com?a=b&': 'https://google.com/?a=b',
'https://google.com?a=b&c': 'https://google.com/?a=b&c',
'https://google.com?a=b&c=': 'https://google.com/?a=b&c=',
'https://google.com?a=b&c=d': 'https://google.com/?a=b&c=d',
'https://google.com?a=b&c=d?': 'https://google.com/?a=b&c=d',
'https://google.com?a=b&c=d&': 'https://google.com/?a=b&c=d',
'https://google.com?a=b&c=d&e=': 'https://google.com/?a=b&c=d&e=',
'https://google.com?a=b&c=d&e=f': 'https://google.com/?a=b&c=d&e=f',
};
Object.keys(LINK_MAP).forEach((input) => {
it('should auto convert links to anchor: ' + input, () => {
editor.insertHTML(input);
const link = document.querySelector('a');
expect(link.href).toBe(LINK_MAP[input]);
editor.setHTML('');
});
});
it('should auto convert a part of the link to an anchor', () => {
editor.insertHTML(`
dew@fre.fr dewdwe @dew
`);
const link = document.querySelector('a');
expect(link.textContent).toBe('dew@fre.fr');
expect(link.href).toBe('mailto:dew@fre.fr');
editor.setHTML('');
});
it('should not auto convert non links to anchor', () => {
editor.insertHTML(`
dewdwe @dew
deww.de
monique.fre
google.com
`);
const link = document.querySelector('a');
expect(link).toBe(null);
editor.setHTML('');
});
});
afterEach(() => {
editor = null;
document.body.innerHTML = `<div id="squire">`;
});
});

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "ES2020",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noImplicitThis": true,
// "noUncheckedIndexedAccess": true
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true
},
"include": ["./source"]
}