├── .editorconfig
├── .eslintrc.json
├── .github
└── workflows
│ ├── _test.yml
│ ├── main.yml
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── api-extractor.json
├── package-lock.json
├── package.json
├── scripts
└── release.cjs
├── src
├── attributor
│ ├── attributor.ts
│ ├── class.ts
│ ├── store.ts
│ └── style.ts
├── blot
│ ├── abstract
│ │ ├── blot.ts
│ │ ├── container.ts
│ │ ├── leaf.ts
│ │ ├── parent.ts
│ │ └── shadow.ts
│ ├── block.ts
│ ├── embed.ts
│ ├── inline.ts
│ ├── scroll.ts
│ └── text.ts
├── collection
│ ├── linked-list.ts
│ └── linked-node.ts
├── error.ts
├── parchment.ts
├── registry.ts
└── scope.ts
├── tests
├── __helpers__
│ └── registry
│ │ ├── attributor.ts
│ │ ├── block.ts
│ │ ├── break.ts
│ │ ├── embed.ts
│ │ ├── inline.ts
│ │ └── list.ts
├── setup.ts
├── types
│ ├── attributor.test-d.ts
│ └── parent.test-d.ts
└── unit
│ ├── attributor.test.ts
│ ├── block.test.ts
│ ├── blot.test.ts
│ ├── container.test.ts
│ ├── embed.test.ts
│ ├── inline.test.ts
│ ├── lifecycle.test.ts
│ ├── linked-list.test.ts
│ ├── parent.test.ts
│ ├── registry.test.ts
│ ├── scroll.test.ts
│ └── text.test.ts
├── tsconfig.json
└── vite.config.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | # editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 2
8 | end_of_line = lf
9 | charset = utf-8
10 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:prettier/recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:require-extensions/recommended"
11 | ],
12 | "ignorePatterns": ["vite.config.ts", "scripts"],
13 | "parser": "@typescript-eslint/parser",
14 | "plugins": ["@typescript-eslint", "require-extensions", "tree-shaking"],
15 | "parserOptions": {
16 | "ecmaVersion": "latest",
17 | "sourceType": "module",
18 | "project": "./tsconfig.json"
19 | },
20 | "rules": {
21 | "@typescript-eslint/no-explicit-any": ["off"],
22 | "@typescript-eslint/no-unused-vars": [
23 | "error",
24 | {
25 | "argsIgnorePattern": "^_"
26 | }
27 | ],
28 | "tree-shaking/no-side-effects-in-initialization": ["error"]
29 | },
30 | "overrides": [
31 | {
32 | "files": ["tests/**/*"],
33 | "rules": {
34 | "tree-shaking/no-side-effects-in-initialization": ["off"]
35 | }
36 | }
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/.github/workflows/_test.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | workflow_call:
4 | jobs:
5 | test:
6 | runs-on: ubuntu-latest
7 | # https://playwright.dev/docs/ci#via-containers
8 | container:
9 | image: mcr.microsoft.com/playwright:v1.42.1-jammy
10 | steps:
11 | - uses: actions/checkout@v3
12 | - uses: actions/setup-node@v3
13 | with:
14 | node-version: 20
15 | - run: npm ci
16 | - run: npm run lint
17 | - run: npm test
18 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: 'Run tests'
2 | on:
3 | push:
4 | branches: [main]
5 | pull_request:
6 | branches: [main]
7 |
8 | jobs:
9 | test:
10 | uses: ./.github/workflows/_test.yml
11 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | version:
7 | description: 'npm version. Examples: "2.0.0", "2.0.0-beta.0". To deploy an experimental version, type "experimental".'
8 | default: 'experimental'
9 | required: true
10 | dry-run:
11 | description: 'Only create a tarball, do not publish to npm or create a release on GitHub.'
12 | default: true
13 | type: boolean
14 | required: true
15 |
16 | jobs:
17 | test:
18 | uses: ./.github/workflows/_test.yml
19 |
20 | release:
21 | runs-on: ubuntu-latest
22 | needs: test
23 |
24 | steps:
25 | - name: Git checkout
26 | uses: actions/checkout@v3
27 |
28 | - name: Use Node.js
29 | uses: actions/setup-node@v3
30 | with:
31 | node-version: 20
32 |
33 | - run: npm ci
34 | - run: ./scripts/release.cjs --version ${{ github.event.inputs.version }} ${{ github.event.inputs.dry-run == 'true' && '--dry-run' || '' }}
35 | env:
36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
38 |
39 | - name: Archive npm package tarball
40 | uses: actions/upload-artifact@v3
41 | with:
42 | name: npm
43 | path: |
44 | *.tgz
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.build
2 | /dist
3 | /node_modules
4 |
5 | *.log
6 | *.notes
7 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # [Unreleased]
2 |
3 | # 3.0.0
4 |
5 | # 3.0.0-rc.1
6 |
7 | - Allow ref blots to be null
8 |
9 | # 3.0.0-rc.0
10 |
11 | - Keep identify names in built code bundle
12 |
13 | # 3.0.0-beta.0
14 |
15 | - Make the bundle a valid ESM page
16 | - Improve typings for Blot
17 |
18 | # 3.0.0-alpha.2
19 |
20 | - Improved typing for Attributor and Registry.
21 |
22 | # 3.0.0-alpha.1
23 |
24 | - Fix ESM bundle not exposed in package.json.
25 |
26 | # 3.0.0-alpha.0
27 |
28 | - BREAKING: Types are now directly exposed from `parchment`.
29 | - Added ESM bundle.
30 | - Fixed typing for `Parent#descendants`.
31 | - Updated `Blot.tagName` to allow `string[]`.
32 |
33 | # 2.0.1
34 |
35 | - `Registry.find()` handles restricted nodes on Firefox.
36 |
37 | # 2.0.0
38 |
39 | - Add `ParentBlot`. `ContainerBlot` now inherits `ParentBlot`.
40 | - Add UI node support with `ParentBlot#attachUI()`.
41 | - Fix compatibility with TypeScript 3.7.
42 | - Ensure `Scroll#find()` does not return blots in child scrolls.
43 |
44 | ## Breaking Changes
45 |
46 | - The default export is removed. Use named exports instead:
47 |
48 | Before:
49 |
50 | ```ts
51 | import Parchment from 'parchment';
52 | const blot = Parchment.create(/* ... */);
53 | class MyContainer extends Parchment.Container {}
54 | ```
55 |
56 | After:
57 |
58 | ```ts
59 | import { Registry, ContainerBlot } from 'parchment';
60 | const blot = Registry.create(/* ... */);
61 | class MyContainer extends Parchment.ContainerBlot {}
62 | ```
63 |
64 | - `ParentBlot.defaultChild` requires a blot constructor instead of a string.
65 | - `Blot#replace()` is removed. Use `Blot#replaceWith()` instead.
66 | - `Blot#insertInto()` is removed. Use `Parent#insertBefore()` instead.
67 | - `FormatBlot` is removed. Now `BlockBlot` and `InlineBlot` implement `Formattable` interface directly.
68 | - **Typing**: `Blot#prev`, `Blot#next` and `Blot#split()` may return `null`.
69 | - **Typing**: Other misc type declaration changes.
70 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015-2021, Jason Chen
2 | Copyright (c) 2022-2024, Slab, Inc.
3 | All rights reserved.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions
7 | are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright
10 | notice, this list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright
13 | notice, this list of conditions and the following disclaimer in the
14 | documentation and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
22 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
23 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Parchment [](https://github.com/quilljs/parchment/actions?query=branch%3Amain)
2 |
3 | Parchment is [Quill](https://quilljs.com)'s document model. It is a parallel tree structure to the DOM tree, and provides functionality useful for content editors, like Quill. A Parchment tree is made up of [Blots](#blots), which mirror a DOM node counterpart. Blots can provide structure, formatting, and/or content. [Attributors](#attributors) can also provide lightweight formatting information.
4 |
5 | **Note:** You should never instantiate a Blot yourself with `new`. This may prevent necessary lifecycle functionality of a Blot. Use the [Registry](#registry)'s `create()` method instead.
6 |
7 | `npm install parchment`
8 |
9 | See [Cloning Medium with Parchment](https://quilljs.com/guides/cloning-medium-with-parchment/) for a guide on how Quill uses Parchment its document model.
10 |
11 | ## Blots
12 |
13 | Blots are the basic building blocks of a Parchment document. Several basic implementations such as [Block](#block-blot), [Inline](#inline-blot), and [Embed](#embed-blot) are provided. In general you will want to extend one of these, instead of building from scratch. After implementation, blots need to be [registered](#registry) before usage.
14 |
15 | At the very minimum a Blot must be named with a static `blotName` and associated with either a `tagName` or `className`. If a Blot is defined with both a tag and class, the class takes precedence, but the tag may be used as a fallback. Blots must also have a [scope](#registry), which determine if it is inline or block.
16 |
17 | ```typescript
18 | class Blot {
19 | static blotName: string;
20 | static className: string;
21 | static tagName: string | string[];
22 | static scope: Scope;
23 |
24 | domNode: Node;
25 | prev: Blot | null;
26 | next: Blot | null;
27 | parent: Blot;
28 |
29 | // Creates corresponding DOM node
30 | static create(value?: any): Node;
31 |
32 | constructor(domNode: Node, value?: any);
33 |
34 | // For leaves, length of blot's value()
35 | // For parents, sum of children's values
36 | length(): Number;
37 |
38 | // Manipulate at given index and length, if applicable.
39 | // Will often pass call onto appropriate child.
40 | deleteAt(index: number, length: number);
41 | formatAt(index: number, length: number, format: string, value: any);
42 | insertAt(index: number, text: string);
43 | insertAt(index: number, embed: string, value: any);
44 |
45 | // Returns offset between this blot and an ancestor's
46 | offset(ancestor: Blot = this.parent): number;
47 |
48 | // Called after update cycle completes. Cannot change the value or length
49 | // of the document, and any DOM operation must reduce complexity of the DOM
50 | // tree. A shared context object is passed through all blots.
51 | optimize(context: { [key: string]: any }): void;
52 |
53 | // Called when blot changes, with the mutation records of its change.
54 | // Internal records of the blot values can be updated, and modifications of
55 | // the blot itself is permitted. Can be trigger from user change or API call.
56 | // A shared context object is passed through all blots.
57 | update(mutations: MutationRecord[], context: { [key: string]: any });
58 |
59 | /** Leaf Blots only **/
60 |
61 | // Returns the value represented by domNode if it is this Blot's type
62 | // No checking that domNode can represent this Blot type is required so
63 | // applications needing it should check externally before calling.
64 | static value(domNode): any;
65 |
66 | // Given location represented by node and offset from DOM Selection Range,
67 | // return index to that location.
68 | index(node: Node, offset: number): number;
69 |
70 | // Given index to location within blot, return node and offset representing
71 | // that location, consumable by DOM Selection Range
72 | position(index: number, inclusive: boolean): [Node, number];
73 |
74 | // Return value represented by this blot
75 | // Should not change without interaction from API or
76 | // user change detectable by update()
77 | value(): any;
78 |
79 | /** Parent blots only **/
80 |
81 | // Whitelist array of Blots that can be direct children.
82 | static allowedChildren: Registry.BlotConstructor[];
83 |
84 | // Default child blot to be inserted if this blot becomes empty.
85 | static defaultChild: Registry.BlotConstructor;
86 |
87 | children: LinkedList;
88 |
89 | // Called during construction, should fill its own children LinkedList.
90 | build();
91 |
92 | // Useful search functions for descendant(s), should not modify
93 | descendant(type: BlotClass, index: number, inclusive): Blot;
94 | descendants(type: BlotClass, index: number, length: number): Blot[];
95 |
96 | /** Formattable blots only **/
97 |
98 | // Returns format values represented by domNode if it is this Blot's type
99 | // No checking that domNode is this Blot's type is required.
100 | static formats(domNode: Node);
101 |
102 | // Apply format to blot. Should not pass onto child or other blot.
103 | format(format: name, value: any);
104 |
105 | // Return formats represented by blot, including from Attributors.
106 | formats(): Object;
107 | }
108 | ```
109 |
110 | ### Example
111 |
112 | Implementation for a Blot representing a link, which is a parent, inline scoped, and formattable.
113 |
114 | ```typescript
115 | import { InlineBlot, register } from 'parchment';
116 |
117 | class LinkBlot extends InlineBlot {
118 | static blotName = 'link';
119 | static tagName = 'A';
120 |
121 | static create(url) {
122 | let node = super.create();
123 | node.setAttribute('href', url);
124 | node.setAttribute('target', '_blank');
125 | node.setAttribute('title', node.textContent);
126 | return node;
127 | }
128 |
129 | static formats(domNode) {
130 | return domNode.getAttribute('href') || true;
131 | }
132 |
133 | format(name, value) {
134 | if (name === 'link' && value) {
135 | this.domNode.setAttribute('href', value);
136 | } else {
137 | super.format(name, value);
138 | }
139 | }
140 |
141 | formats() {
142 | let formats = super.formats();
143 | formats['link'] = LinkBlot.formats(this.domNode);
144 | return formats;
145 | }
146 | }
147 |
148 | register(LinkBlot);
149 | ```
150 |
151 | Quill also provides many great example implementations in its [source code](https://github.com/quilljs/quill/tree/develop/packages/quill/src/formats).
152 |
153 | ### Block Blot
154 |
155 | Basic implementation of a block scoped formattable parent Blot. Formatting a block blot by default will replace the appropriate subsection of the blot.
156 |
157 | ### Inline Blot
158 |
159 | Basic implementation of an inline scoped formattable parent Blot. Formatting an inline blot by default either wraps itself with another blot or passes the call to the appropriate child.
160 |
161 | ### Embed Blot
162 |
163 | Basic implementation of a non-text leaf blot, that is formattable. Its corresponding DOM node will often be a [Void Element](https://www.w3.org/TR/html5/syntax.html#void-elements), but can be a [Normal Element](https://www.w3.org/TR/html5/syntax.html#normal-elements). In these cases Parchment will not manipulate or generally be aware of the element's children, and it will be important to correctly implement the blot's `index()` and `position()` functions to correctly work with cursors/selections.
164 |
165 | ### Scroll
166 |
167 | The root parent blot of a Parchment document. It is not formattable.
168 |
169 | ## Attributors
170 |
171 | Attributors are the alternative, more lightweight, way to represent formats. Their DOM counterpart is an [Attribute](https://www.w3.org/TR/html5/syntax.html#attributes-0). Like a DOM attribute's relationship to a node, Attributors are meant to belong to Blots. Calling `formats()` on an [Inline](#inline-blot) or [Block](#block-blot) blot will return both the format of the corresponding DOM node represents (if any) and the formats the DOM node's attributes represent (if any).
172 |
173 | Attributors have the following interface:
174 |
175 | ```typescript
176 | class Attributor {
177 | attrName: string;
178 | keyName: string;
179 | scope: Scope;
180 | whitelist: string[];
181 |
182 | constructor(attrName: string, keyName: string, options: Object = {});
183 | add(node: HTMLElement, value: string): boolean;
184 | canAdd(node: HTMLElement, value: string): boolean;
185 | remove(node: HTMLElement);
186 | value(node: HTMLElement);
187 | }
188 | ```
189 |
190 | Note custom attributors are instances, rather than class definitions like Blots. Similar to Blots, instead of creating from scratch, you will probably want to use existing Attributor implementations, such as the base [Attributor](#attributor), [Class Attributor](#class-attributor) or [Style Attributor](#style-attributor).
191 |
192 | The implementation for Attributors is surprisingly simple, and its [source code](https://github.com/quilljs/parchment/tree/main/src/attributor) may be another source of understanding.
193 |
194 | ### Attributor
195 |
196 | Uses a plain attribute to represent formats.
197 |
198 | ```js
199 | import { Attributor, register } from 'parchment';
200 |
201 | let Width = new Attributor('width', 'width');
202 | register(Width);
203 |
204 | let imageNode = document.createElement('img');
205 |
206 | Width.add(imageNode, '10px');
207 | console.log(imageNode.outerHTML); // Will print
208 | Width.value(imageNode); // Will return 10px
209 | Width.remove(imageNode);
210 | console.log(imageNode.outerHTML); // Will print
211 | ```
212 |
213 | ### Class Attributor
214 |
215 | Uses a class name pattern to represent formats.
216 |
217 | ```js
218 | import { ClassAttributor, register } from 'parchment';
219 |
220 | let Align = new ClassAttributor('align', 'blot-align');
221 | register(Align);
222 |
223 | let node = document.createElement('div');
224 | Align.add(node, 'right');
225 | console.log(node.outerHTML); // Will print
226 | ```
227 |
228 | ### Style Attributor
229 |
230 | Uses inline styles to represent formats.
231 |
232 | ```js
233 | import { StyleAttributor, register } from 'parchment';
234 |
235 | let Align = new StyleAttributor('align', 'text-align', {
236 | whitelist: ['right', 'center', 'justify'], // Having no value implies left align
237 | });
238 | register(Align);
239 |
240 | let node = document.createElement('div');
241 | Align.add(node, 'right');
242 | console.log(node.outerHTML); // Will print
243 | ```
244 |
245 | ## Registry
246 |
247 | All methods are accessible from Parchment ex. `Parchment.create('bold')`.
248 |
249 | ```typescript
250 | // Creates a blot given a name or DOM node.
251 | // When given just a scope, creates blot the same name as scope
252 | create(domNode: Node, value?: any): Blot;
253 | create(blotName: string, value?: any): Blot;
254 | create(scope: Scope): Blot;
255 |
256 | // Given DOM node, find corresponding Blot.
257 | // Bubbling is useful when searching for a Embed Blot with its corresponding
258 | // DOM node's descendant nodes.
259 | find(domNode: Node, bubble: boolean = false): Blot;
260 |
261 | // Search for a Blot or Attributor
262 | // When given just a scope, finds blot with same name as scope
263 | query(tagName: string, scope: Scope = Scope.ANY): BlotClass;
264 | query(blotName: string, scope: Scope = Scope.ANY): BlotClass;
265 | query(domNode: Node, scope: Scope = Scope.ANY): BlotClass;
266 | query(scope: Scope): BlotClass;
267 | query(attributorName: string, scope: Scope = Scope.ANY): Attributor;
268 |
269 | // Register Blot class definition or Attributor instance
270 | register(BlotClass | Attributor);
271 | ```
272 |
--------------------------------------------------------------------------------
/api-extractor.json:
--------------------------------------------------------------------------------
1 | /**
2 | * Config file for API Extractor. For more info, please visit: https://api-extractor.com
3 | */
4 | {
5 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
6 | "mainEntryPointFilePath": "dist/typings/src/parchment.d.ts",
7 | "dtsRollup": {
8 | "enabled": true,
9 | "untrimmedFilePath": "/dist/parchment.d.ts"
10 | },
11 | "docModel": {
12 | "enabled": false
13 | },
14 | "apiReport": {
15 | "enabled": false
16 | },
17 | "tsdocMetadata": {
18 | "enabled": false
19 | },
20 | "messages": {
21 | "extractorMessageReporting": {
22 | "ae-missing-release-tag": {
23 | "logLevel": "none"
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "parchment",
3 | "version": "3.0.0",
4 | "description": "A document model for rich text editors",
5 | "author": "Jason Chen ",
6 | "homepage": "http://quilljs.com/docs/parchment",
7 | "main": "./dist/parchment.js",
8 | "types": "./dist/parchment.d.ts",
9 | "type": "module",
10 | "sideEffects": false,
11 | "files": [
12 | "tsconfig.json",
13 | "dist",
14 | "src"
15 | ],
16 | "devDependencies": {
17 | "@arethetypeswrong/cli": "^0.15.1",
18 | "@microsoft/api-extractor": "^7.42.3",
19 | "@types/node": "^18.15.11",
20 | "@typescript-eslint/eslint-plugin": "^7.2.0",
21 | "@typescript-eslint/parser": "^7.2.0",
22 | "@vitest/browser": "^1.4.0",
23 | "del-cli": "^5.1.0",
24 | "eslint": "^8.46.0",
25 | "eslint-config-prettier": "^9.1.0",
26 | "eslint-plugin-prettier": "^5.1.3",
27 | "eslint-plugin-require-extensions": "^0.1.3",
28 | "eslint-plugin-tree-shaking": "^1.12.1",
29 | "playwright": "1.42.1",
30 | "prettier": "^3.2.5",
31 | "typescript": "^5.4.2",
32 | "vite": "^5.1.6",
33 | "vitest": "^1.4.0"
34 | },
35 | "prettier": {
36 | "singleQuote": true
37 | },
38 | "license": "BSD-3-Clause",
39 | "repository": "github:quilljs/parchment",
40 | "scripts": {
41 | "build": "npm run build:bundle && npm run build:types",
42 | "build:bundle": "vite build",
43 | "build:types": "tsc --emitDeclarationOnly && api-extractor run && del-cli dist/typings",
44 | "lint": "eslint 'src/**/*.ts' 'tests/**/*.ts'",
45 | "prepare": "npm run build",
46 | "test": "npm run test:unit",
47 | "test:unit": "vitest --typecheck",
48 | "test:pkg": "attw $(npm pack)"
49 | },
50 | "bugs": {
51 | "url": "https://github.com/quilljs/parchment/issues"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/scripts/release.cjs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const exec = require('node:child_process').execSync;
4 | const fs = require('node:fs');
5 | const crypto = require('node:crypto');
6 | const { parseArgs } = require('node:util');
7 |
8 | const args = parseArgs({
9 | options: {
10 | version: { type: 'string' },
11 | 'dry-run': { type: 'boolean', default: false },
12 | },
13 | });
14 |
15 | const dryRun = args.values['dry-run'];
16 |
17 | if (dryRun) {
18 | console.log('Running in "dry-run" mode');
19 | }
20 |
21 | const exitWithError = (message) => {
22 | console.error(`Exit with error: ${message}`);
23 | process.exit(1);
24 | };
25 |
26 | if (!process.env.CI) {
27 | exitWithError('The script should only be run in CI');
28 | }
29 |
30 | exec('echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc');
31 |
32 | exec('git config --global user.name "Zihua Li"');
33 | exec('git config --global user.email "635902+luin@users.noreply.github.com"');
34 |
35 | /*
36 | * Check that the git working directory is clean
37 | */
38 | if (exec('git status --porcelain').length) {
39 | exitWithError(
40 | 'Make sure the git working directory is clean before releasing',
41 | );
42 | }
43 |
44 | /*
45 | * Check that the version is valid. Also extract the dist-tag from the version.
46 | */
47 | const [version, distTag] = (() => {
48 | const inputVersion = args.values.version;
49 | if (!inputVersion) {
50 | exitWithError('Missing required argument: "--version "');
51 | }
52 |
53 | if (inputVersion === 'experimental') {
54 | const randomId = crypto
55 | .randomBytes(Math.ceil(9 / 2))
56 | .toString('hex')
57 | .slice(0, 9);
58 |
59 | return [
60 | `0.0.0-experimental-${randomId}-${new Date()
61 | .toISOString()
62 | .slice(0, 10)
63 | .replace(/-/g, '')}`,
64 | 'experimental',
65 | ];
66 | }
67 |
68 | const match = inputVersion.match(
69 | /^(?:[0-9]+\.){2}(?:[0-9]+)(?:-(dev|alpha|beta|rc)\.[0-9]+)?$/,
70 | );
71 | if (!match) {
72 | exitWithError(`Invalid version: ${inputVersion}`);
73 | }
74 |
75 | return [inputVersion, match[1] || 'latest'];
76 | })();
77 |
78 | /*
79 | * Get the current version
80 | */
81 | const currentVersion = JSON.parse(
82 | fs.readFileSync('package.json', 'utf-8'),
83 | ).version;
84 | console.log(
85 | `Releasing with version: ${currentVersion} -> ${version} and dist-tag: ${distTag}`,
86 | );
87 |
88 | /*
89 | * Update version in CHANGELOG.md
90 | */
91 | console.log('Updating CHANGELOG.md and bumping versions');
92 | const changelog = fs.readFileSync('CHANGELOG.md', 'utf8');
93 | const UNRELEASED_PLACEHOLDER = '# [Unreleased]';
94 |
95 | const index = changelog.indexOf(UNRELEASED_PLACEHOLDER);
96 | if (index === -1) {
97 | exitWithError(`Could not find "${UNRELEASED_PLACEHOLDER}" in CHANGELOG.md`);
98 | }
99 | let nextVersionIndex = changelog.indexOf('\n# ', index);
100 | if (nextVersionIndex === -1) {
101 | nextVersionIndex = changelog.length - 1;
102 | }
103 |
104 | const releaseNots = changelog
105 | .substring(index + UNRELEASED_PLACEHOLDER.length, nextVersionIndex)
106 | .trim();
107 |
108 | fs.writeFileSync(
109 | 'CHANGELOG.md',
110 | changelog.replace(
111 | UNRELEASED_PLACEHOLDER,
112 | `${UNRELEASED_PLACEHOLDER}\n\n# ${version}`,
113 | ),
114 | );
115 |
116 | /*
117 | * Bump npm versions
118 | */
119 | exec('git add CHANGELOG.md');
120 | exec(`npm version ${version} -f`);
121 |
122 | const pushCommand = `git push origin ${process.env.GITHUB_REF_NAME} --follow-tags`;
123 | if (dryRun) {
124 | console.log(`Skipping: "${pushCommand}" in dry-run mode`);
125 | } else if (distTag === 'experimental') {
126 | console.log(`Skipping: "${pushCommand}" for experimental version`);
127 | } else {
128 | exec(pushCommand);
129 | }
130 |
131 | /*
132 | * Build Quill package
133 | */
134 | console.log('Building Quill');
135 | exec('npm run build');
136 |
137 | /*
138 | * Publish Quill package
139 | */
140 | console.log('Publishing Quill');
141 | if (JSON.parse(fs.readFileSync('package.json', 'utf-8')).version !== version) {
142 | exitWithError('Version mismatch');
143 | }
144 |
145 | exec(`npm publish --tag ${distTag}${dryRun ? ' --dry-run' : ''}`);
146 |
147 | /*
148 | * Create GitHub release
149 | */
150 | if (distTag === 'experimental') {
151 | console.log('Skipping GitHub release for experimental version');
152 | } else {
153 | const filename = `release-note-${version}-${(Math.random() * 1000) | 0}.txt`;
154 | fs.writeFileSync(filename, releaseNots);
155 | try {
156 | const prereleaseFlag = distTag === 'latest' ? '--latest' : ' --prerelease';
157 | const releaseCommand = `gh release create v${version} ${prereleaseFlag} -t "Version ${version}" --notes-file "${filename}"`;
158 | if (dryRun) {
159 | console.log(`Skipping: "${releaseCommand}" in dry-run mode`);
160 | console.log(`Release note:\n${releaseNots}`);
161 | } else {
162 | exec(releaseCommand);
163 | }
164 | } finally {
165 | fs.unlinkSync(filename);
166 | }
167 | }
168 |
169 | /*
170 | * Create npm package tarball
171 | */
172 | exec('npm pack');
173 |
--------------------------------------------------------------------------------
/src/attributor/attributor.ts:
--------------------------------------------------------------------------------
1 | import Scope from '../scope.js';
2 |
3 | export interface AttributorOptions {
4 | scope?: Scope;
5 | whitelist?: string[];
6 | }
7 |
8 | export default class Attributor {
9 | public static keys(node: HTMLElement): string[] {
10 | return Array.from(node.attributes).map((item: Attr) => item.name);
11 | }
12 |
13 | public scope: Scope;
14 | public whitelist: string[] | undefined;
15 |
16 | constructor(
17 | public readonly attrName: string,
18 | public readonly keyName: string,
19 | options: AttributorOptions = {},
20 | ) {
21 | const attributeBit = Scope.TYPE & Scope.ATTRIBUTE;
22 | this.scope =
23 | options.scope != null
24 | ? // Ignore type bits, force attribute bit
25 | (options.scope & Scope.LEVEL) | attributeBit
26 | : Scope.ATTRIBUTE;
27 | if (options.whitelist != null) {
28 | this.whitelist = options.whitelist;
29 | }
30 | }
31 |
32 | public add(node: HTMLElement, value: any): boolean {
33 | if (!this.canAdd(node, value)) {
34 | return false;
35 | }
36 | node.setAttribute(this.keyName, value);
37 | return true;
38 | }
39 |
40 | public canAdd(_node: HTMLElement, value: any): boolean {
41 | if (this.whitelist == null) {
42 | return true;
43 | }
44 | if (typeof value === 'string') {
45 | return this.whitelist.indexOf(value.replace(/["']/g, '')) > -1;
46 | } else {
47 | return this.whitelist.indexOf(value) > -1;
48 | }
49 | }
50 |
51 | public remove(node: HTMLElement): void {
52 | node.removeAttribute(this.keyName);
53 | }
54 |
55 | public value(node: HTMLElement): any {
56 | const value = node.getAttribute(this.keyName);
57 | if (this.canAdd(node, value) && value) {
58 | return value;
59 | }
60 | return '';
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/attributor/class.ts:
--------------------------------------------------------------------------------
1 | import Attributor from './attributor.js';
2 |
3 | function match(node: HTMLElement, prefix: string): string[] {
4 | const className = node.getAttribute('class') || '';
5 | return className
6 | .split(/\s+/)
7 | .filter((name) => name.indexOf(`${prefix}-`) === 0);
8 | }
9 |
10 | class ClassAttributor extends Attributor {
11 | public static keys(node: HTMLElement): string[] {
12 | return (node.getAttribute('class') || '')
13 | .split(/\s+/)
14 | .map((name) => name.split('-').slice(0, -1).join('-'));
15 | }
16 |
17 | public add(node: HTMLElement, value: any): boolean {
18 | if (!this.canAdd(node, value)) {
19 | return false;
20 | }
21 | this.remove(node);
22 | node.classList.add(`${this.keyName}-${value}`);
23 | return true;
24 | }
25 |
26 | public remove(node: HTMLElement): void {
27 | const matches = match(node, this.keyName);
28 | matches.forEach((name) => {
29 | node.classList.remove(name);
30 | });
31 | if (node.classList.length === 0) {
32 | node.removeAttribute('class');
33 | }
34 | }
35 |
36 | public value(node: HTMLElement): any {
37 | const result = match(node, this.keyName)[0] || '';
38 | const value = result.slice(this.keyName.length + 1); // +1 for hyphen
39 | return this.canAdd(node, value) ? value : '';
40 | }
41 | }
42 |
43 | export default ClassAttributor;
44 |
--------------------------------------------------------------------------------
/src/attributor/store.ts:
--------------------------------------------------------------------------------
1 | import type { Formattable } from '../blot/abstract/blot.js';
2 | import Registry from '../registry.js';
3 | import Scope from '../scope.js';
4 | import Attributor from './attributor.js';
5 | import ClassAttributor from './class.js';
6 | import StyleAttributor from './style.js';
7 |
8 | class AttributorStore {
9 | private attributes: { [key: string]: Attributor } = {};
10 | private domNode: HTMLElement;
11 |
12 | constructor(domNode: HTMLElement) {
13 | this.domNode = domNode;
14 | this.build();
15 | }
16 |
17 | public attribute(attribute: Attributor, value: any): void {
18 | // verb
19 | if (value) {
20 | if (attribute.add(this.domNode, value)) {
21 | if (attribute.value(this.domNode) != null) {
22 | this.attributes[attribute.attrName] = attribute;
23 | } else {
24 | delete this.attributes[attribute.attrName];
25 | }
26 | }
27 | } else {
28 | attribute.remove(this.domNode);
29 | delete this.attributes[attribute.attrName];
30 | }
31 | }
32 |
33 | public build(): void {
34 | this.attributes = {};
35 | const blot = Registry.find(this.domNode);
36 | if (blot == null) {
37 | return;
38 | }
39 | const attributes = Attributor.keys(this.domNode);
40 | const classes = ClassAttributor.keys(this.domNode);
41 | const styles = StyleAttributor.keys(this.domNode);
42 | attributes
43 | .concat(classes)
44 | .concat(styles)
45 | .forEach((name) => {
46 | const attr = blot.scroll.query(name, Scope.ATTRIBUTE);
47 | if (attr instanceof Attributor) {
48 | this.attributes[attr.attrName] = attr;
49 | }
50 | });
51 | }
52 |
53 | public copy(target: Formattable): void {
54 | Object.keys(this.attributes).forEach((key) => {
55 | const value = this.attributes[key].value(this.domNode);
56 | target.format(key, value);
57 | });
58 | }
59 |
60 | public move(target: Formattable): void {
61 | this.copy(target);
62 | Object.keys(this.attributes).forEach((key) => {
63 | this.attributes[key].remove(this.domNode);
64 | });
65 | this.attributes = {};
66 | }
67 |
68 | public values(): { [key: string]: any } {
69 | return Object.keys(this.attributes).reduce(
70 | (attributes: { [key: string]: any }, name: string) => {
71 | attributes[name] = this.attributes[name].value(this.domNode);
72 | return attributes;
73 | },
74 | {},
75 | );
76 | }
77 | }
78 |
79 | export default AttributorStore;
80 |
--------------------------------------------------------------------------------
/src/attributor/style.ts:
--------------------------------------------------------------------------------
1 | import Attributor from './attributor.js';
2 |
3 | function camelize(name: string): string {
4 | const parts = name.split('-');
5 | const rest = parts
6 | .slice(1)
7 | .map((part: string) => part[0].toUpperCase() + part.slice(1))
8 | .join('');
9 | return parts[0] + rest;
10 | }
11 |
12 | class StyleAttributor extends Attributor {
13 | public static keys(node: HTMLElement): string[] {
14 | return (node.getAttribute('style') || '').split(';').map((value) => {
15 | const arr = value.split(':');
16 | return arr[0].trim();
17 | });
18 | }
19 |
20 | public add(node: HTMLElement, value: any): boolean {
21 | if (!this.canAdd(node, value)) {
22 | return false;
23 | }
24 | // @ts-expect-error Fix me later
25 | node.style[camelize(this.keyName)] = value;
26 | return true;
27 | }
28 |
29 | public remove(node: HTMLElement): void {
30 | // @ts-expect-error Fix me later
31 | node.style[camelize(this.keyName)] = '';
32 | if (!node.getAttribute('style')) {
33 | node.removeAttribute('style');
34 | }
35 | }
36 |
37 | public value(node: HTMLElement): any {
38 | // @ts-expect-error Fix me later
39 | const value = node.style[camelize(this.keyName)];
40 | return this.canAdd(node, value) ? value : '';
41 | }
42 | }
43 |
44 | export default StyleAttributor;
45 |
--------------------------------------------------------------------------------
/src/blot/abstract/blot.ts:
--------------------------------------------------------------------------------
1 | import type LinkedList from '../../collection/linked-list.js';
2 | import type LinkedNode from '../../collection/linked-node.js';
3 | import type { RegistryDefinition } from '../../registry.js';
4 | import Scope from '../../scope.js';
5 |
6 | export interface BlotConstructor {
7 | new (...args: any[]): Blot;
8 | /**
9 | * Creates corresponding DOM node
10 | */
11 | create(value?: any): Node;
12 |
13 | blotName: string;
14 | tagName: string | string[];
15 | scope: Scope;
16 | className?: string;
17 |
18 | requiredContainer?: BlotConstructor;
19 | allowedChildren?: BlotConstructor[];
20 | defaultChild?: BlotConstructor;
21 | }
22 |
23 | /**
24 | * Blots are the basic building blocks of a Parchment document.
25 | *
26 | * Several basic implementations such as Block, Inline, and Embed are provided.
27 | * In general you will want to extend one of these, instead of building from scratch.
28 | * After implementation, blots need to be registered before usage.
29 | *
30 | * At the very minimum a Blot must be named with a static blotName and associated with either a tagName or className.
31 | * If a Blot is defined with both a tag and class, the class takes precedence, but the tag may be used as a fallback.
32 | * Blots must also have a scope, which determine if it is inline or block.
33 | */
34 | export interface Blot extends LinkedNode {
35 | scroll: Root;
36 | parent: Parent;
37 | prev: Blot | null;
38 | next: Blot | null;
39 | domNode: Node;
40 |
41 | statics: BlotConstructor;
42 |
43 | attach(): void;
44 | clone(): Blot;
45 | detach(): void;
46 | isolate(index: number, length: number): Blot;
47 |
48 | /**
49 | * For leaves, length of blot's value()
50 | * For parents, sum of children's values
51 | */
52 | length(): number;
53 |
54 | /**
55 | * Returns offset between this blot and an ancestor's
56 | */
57 | offset(root?: Blot): number;
58 | remove(): void;
59 | replaceWith(name: string, value: any): Blot;
60 | replaceWith(replacement: Blot): Blot;
61 | split(index: number, force?: boolean): Blot | null;
62 | wrap(name: string, value?: any): Parent;
63 | wrap(wrapper: Parent): Parent;
64 |
65 | deleteAt(index: number, length: number): void;
66 | formatAt(index: number, length: number, name: string, value: any): void;
67 | insertAt(index: number, value: string, def?: any): void;
68 |
69 | /**
70 | * Called after update cycle completes. Cannot change the value or length
71 | * of the document, and any DOM operation must reduce complexity of the DOM
72 | * tree. A shared context object is passed through all blots.
73 | */
74 | optimize(context: { [key: string]: any }): void;
75 | optimize(mutations: MutationRecord[], context: { [key: string]: any }): void;
76 |
77 | /**
78 | * Called when blot changes, with the mutation records of its change.
79 | * Internal records of the blot values can be updated, and modifications of
80 | * the blot itself is permitted. Can be trigger from user change or API call.
81 | * A shared context object is passed through all blots.
82 | */
83 | update(mutations: MutationRecord[], context: { [key: string]: any }): void;
84 | }
85 |
86 | export interface Parent extends Blot {
87 | children: LinkedList;
88 | domNode: HTMLElement;
89 |
90 | appendChild(child: Blot): void;
91 | descendant(type: new () => T, index: number): [T, number];
92 | descendant(matcher: (blot: Blot) => boolean, index: number): [T, number];
93 | descendants(type: new () => T, index: number, length: number): T[];
94 | descendants(
95 | matcher: (blot: Blot) => boolean,
96 | index: number,
97 | length: number,
98 | ): T[];
99 | insertBefore(child: Blot, refNode?: Blot | null): void;
100 | moveChildren(parent: Parent, refNode?: Blot | null): void;
101 | path(index: number, inclusive?: boolean): [Blot, number][];
102 | removeChild(child: Blot): void;
103 | unwrap(): void;
104 | }
105 |
106 | export interface Root extends Parent {
107 | create(input: Node | string | Scope, value?: any): Blot;
108 | find(node: Node | null, bubble?: boolean): Blot | null;
109 | query(query: string | Node | Scope, scope?: Scope): RegistryDefinition | null;
110 | }
111 |
112 | export interface Formattable extends Blot {
113 | /**
114 | * Apply format to blot. Should not pass onto child or other blot.
115 | */
116 | format(name: string, value: any): void;
117 |
118 | /**
119 | * Return formats represented by blot, including from Attributors.
120 | */
121 | formats(): { [index: string]: any };
122 | }
123 |
124 | export interface Leaf extends Blot {
125 | index(node: Node, offset: number): number;
126 | position(index: number, inclusive: boolean): [Node, number];
127 | value(): any;
128 | }
129 |
--------------------------------------------------------------------------------
/src/blot/abstract/container.ts:
--------------------------------------------------------------------------------
1 | import Scope from '../../scope.js';
2 | import BlockBlot from '../block.js';
3 | import ParentBlot from './parent.js';
4 |
5 | class ContainerBlot extends ParentBlot {
6 | public static blotName = 'container';
7 | public static scope = Scope.BLOCK_BLOT;
8 | public static tagName: string | string[];
9 |
10 | public prev!: BlockBlot | ContainerBlot | null;
11 | public next!: BlockBlot | ContainerBlot | null;
12 |
13 | public checkMerge(): boolean {
14 | return (
15 | this.next !== null && this.next.statics.blotName === this.statics.blotName
16 | );
17 | }
18 |
19 | public deleteAt(index: number, length: number): void {
20 | super.deleteAt(index, length);
21 | this.enforceAllowedChildren();
22 | }
23 |
24 | public formatAt(
25 | index: number,
26 | length: number,
27 | name: string,
28 | value: any,
29 | ): void {
30 | super.formatAt(index, length, name, value);
31 | this.enforceAllowedChildren();
32 | }
33 |
34 | public insertAt(index: number, value: string, def?: any): void {
35 | super.insertAt(index, value, def);
36 | this.enforceAllowedChildren();
37 | }
38 |
39 | public optimize(context: { [key: string]: any }): void {
40 | super.optimize(context);
41 | if (this.children.length > 0 && this.next != null && this.checkMerge()) {
42 | this.next.moveChildren(this);
43 | this.next.remove();
44 | }
45 | }
46 | }
47 |
48 | export default ContainerBlot;
49 |
--------------------------------------------------------------------------------
/src/blot/abstract/leaf.ts:
--------------------------------------------------------------------------------
1 | import Scope from '../../scope.js';
2 | import type { Leaf } from './blot.js';
3 | import ShadowBlot from './shadow.js';
4 |
5 | class LeafBlot extends ShadowBlot implements Leaf {
6 | public static scope = Scope.INLINE_BLOT;
7 |
8 | /**
9 | * Returns the value represented by domNode if it is this Blot's type
10 | * No checking that domNode can represent this Blot type is required so
11 | * applications needing it should check externally before calling.
12 | */
13 | public static value(_domNode: Node): any {
14 | return true;
15 | }
16 |
17 | /**
18 | * Given location represented by node and offset from DOM Selection Range,
19 | * return index to that location.
20 | */
21 | public index(node: Node, offset: number): number {
22 | if (
23 | this.domNode === node ||
24 | this.domNode.compareDocumentPosition(node) &
25 | Node.DOCUMENT_POSITION_CONTAINED_BY
26 | ) {
27 | return Math.min(offset, 1);
28 | }
29 | return -1;
30 | }
31 |
32 | /**
33 | * Given index to location within blot, return node and offset representing
34 | * that location, consumable by DOM Selection Range
35 | */
36 | public position(index: number, _inclusive?: boolean): [Node, number] {
37 | const childNodes: Node[] = Array.from(this.parent.domNode.childNodes);
38 | let offset = childNodes.indexOf(this.domNode);
39 | if (index > 0) {
40 | offset += 1;
41 | }
42 | return [this.parent.domNode, offset];
43 | }
44 |
45 | /**
46 | * Return value represented by this blot
47 | * Should not change without interaction from API or
48 | * user change detectable by update()
49 | */
50 | public value(): any {
51 | return {
52 | [this.statics.blotName]: this.statics.value(this.domNode) || true,
53 | };
54 | }
55 | }
56 |
57 | export default LeafBlot;
58 |
--------------------------------------------------------------------------------
/src/blot/abstract/parent.ts:
--------------------------------------------------------------------------------
1 | import LinkedList from '../../collection/linked-list.js';
2 | import ParchmentError from '../../error.js';
3 | import Scope from '../../scope.js';
4 | import type { Blot, BlotConstructor, Parent, Root } from './blot.js';
5 | import ShadowBlot from './shadow.js';
6 |
7 | function makeAttachedBlot(node: Node, scroll: Root): Blot {
8 | const found = scroll.find(node);
9 | if (found) return found;
10 | try {
11 | return scroll.create(node);
12 | } catch (e) {
13 | const blot = scroll.create(Scope.INLINE);
14 | Array.from(node.childNodes).forEach((child: Node) => {
15 | blot.domNode.appendChild(child);
16 | });
17 | if (node.parentNode) {
18 | node.parentNode.replaceChild(blot.domNode, node);
19 | }
20 | blot.attach();
21 | return blot;
22 | }
23 | }
24 |
25 | class ParentBlot extends ShadowBlot implements Parent {
26 | /**
27 | * Whitelist array of Blots that can be direct children.
28 | */
29 | public static allowedChildren?: BlotConstructor[];
30 |
31 | /**
32 | * Default child blot to be inserted if this blot becomes empty.
33 | */
34 | public static defaultChild?: BlotConstructor;
35 | public static uiClass = '';
36 |
37 | public children!: LinkedList;
38 | public domNode!: HTMLElement;
39 | public uiNode: HTMLElement | null = null;
40 |
41 | constructor(scroll: Root, domNode: Node) {
42 | super(scroll, domNode);
43 | this.build();
44 | }
45 |
46 | public appendChild(other: Blot): void {
47 | this.insertBefore(other);
48 | }
49 |
50 | public attach(): void {
51 | super.attach();
52 | this.children.forEach((child) => {
53 | child.attach();
54 | });
55 | }
56 |
57 | public attachUI(node: HTMLElement): void {
58 | if (this.uiNode != null) {
59 | this.uiNode.remove();
60 | }
61 | this.uiNode = node;
62 | if (ParentBlot.uiClass) {
63 | this.uiNode.classList.add(ParentBlot.uiClass);
64 | }
65 | this.uiNode.setAttribute('contenteditable', 'false');
66 | this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
67 | }
68 |
69 | /**
70 | * Called during construction, should fill its own children LinkedList.
71 | */
72 | public build(): void {
73 | this.children = new LinkedList();
74 | // Need to be reversed for if DOM nodes already in order
75 | Array.from(this.domNode.childNodes)
76 | .filter((node: Node) => node !== this.uiNode)
77 | .reverse()
78 | .forEach((node: Node) => {
79 | try {
80 | const child = makeAttachedBlot(node, this.scroll);
81 | this.insertBefore(child, this.children.head || undefined);
82 | } catch (err) {
83 | if (err instanceof ParchmentError) {
84 | return;
85 | } else {
86 | throw err;
87 | }
88 | }
89 | });
90 | }
91 |
92 | public deleteAt(index: number, length: number): void {
93 | if (index === 0 && length === this.length()) {
94 | return this.remove();
95 | }
96 | this.children.forEachAt(index, length, (child, offset, childLength) => {
97 | child.deleteAt(offset, childLength);
98 | });
99 | }
100 |
101 | public descendant(
102 | criteria: new (...args: any[]) => T,
103 | index: number,
104 | ): [T | null, number];
105 | public descendant(
106 | criteria: (blot: Blot) => boolean,
107 | index: number,
108 | ): [Blot | null, number];
109 | public descendant(criteria: any, index = 0): [Blot | null, number] {
110 | const [child, offset] = this.children.find(index);
111 | if (
112 | (criteria.blotName == null && criteria(child)) ||
113 | (criteria.blotName != null && child instanceof criteria)
114 | ) {
115 | return [child as any, offset];
116 | } else if (child instanceof ParentBlot) {
117 | return child.descendant(criteria, offset);
118 | } else {
119 | return [null, -1];
120 | }
121 | }
122 |
123 | public descendants(
124 | criteria: new (...args: any[]) => T,
125 | index?: number,
126 | length?: number,
127 | ): T[];
128 | public descendants(
129 | criteria: (blot: Blot) => boolean,
130 | index?: number,
131 | length?: number,
132 | ): Blot[];
133 | public descendants(
134 | criteria: any,
135 | index = 0,
136 | length: number = Number.MAX_VALUE,
137 | ): Blot[] {
138 | let descendants: Blot[] = [];
139 | let lengthLeft = length;
140 | this.children.forEachAt(
141 | index,
142 | length,
143 | (child: Blot, childIndex: number, childLength: number) => {
144 | if (
145 | (criteria.blotName == null && criteria(child)) ||
146 | (criteria.blotName != null && child instanceof criteria)
147 | ) {
148 | descendants.push(child);
149 | }
150 | if (child instanceof ParentBlot) {
151 | descendants = descendants.concat(
152 | child.descendants(criteria, childIndex, lengthLeft),
153 | );
154 | }
155 | lengthLeft -= childLength;
156 | },
157 | );
158 | return descendants;
159 | }
160 |
161 | public detach(): void {
162 | this.children.forEach((child) => {
163 | child.detach();
164 | });
165 | super.detach();
166 | }
167 |
168 | public enforceAllowedChildren(): void {
169 | let done = false;
170 | this.children.forEach((child: Blot) => {
171 | if (done) {
172 | return;
173 | }
174 | const allowed = this.statics.allowedChildren.some(
175 | (def: BlotConstructor) => child instanceof def,
176 | );
177 | if (allowed) {
178 | return;
179 | }
180 | if (child.statics.scope === Scope.BLOCK_BLOT) {
181 | if (child.next != null) {
182 | this.splitAfter(child);
183 | }
184 | if (child.prev != null) {
185 | this.splitAfter(child.prev);
186 | }
187 | child.parent.unwrap();
188 | done = true;
189 | } else if (child instanceof ParentBlot) {
190 | child.unwrap();
191 | } else {
192 | child.remove();
193 | }
194 | });
195 | }
196 |
197 | public formatAt(
198 | index: number,
199 | length: number,
200 | name: string,
201 | value: any,
202 | ): void {
203 | this.children.forEachAt(index, length, (child, offset, childLength) => {
204 | child.formatAt(offset, childLength, name, value);
205 | });
206 | }
207 |
208 | public insertAt(index: number, value: string, def?: any): void {
209 | const [child, offset] = this.children.find(index);
210 | if (child) {
211 | child.insertAt(offset, value, def);
212 | } else {
213 | const blot =
214 | def == null
215 | ? this.scroll.create('text', value)
216 | : this.scroll.create(value, def);
217 | this.appendChild(blot);
218 | }
219 | }
220 |
221 | public insertBefore(childBlot: Blot, refBlot?: Blot | null): void {
222 | if (childBlot.parent != null) {
223 | childBlot.parent.children.remove(childBlot);
224 | }
225 | let refDomNode: Node | null = null;
226 | this.children.insertBefore(childBlot, refBlot || null);
227 | childBlot.parent = this;
228 | if (refBlot != null) {
229 | refDomNode = refBlot.domNode;
230 | }
231 | if (
232 | this.domNode.parentNode !== childBlot.domNode ||
233 | this.domNode.nextSibling !== refDomNode
234 | ) {
235 | this.domNode.insertBefore(childBlot.domNode, refDomNode);
236 | }
237 | childBlot.attach();
238 | }
239 |
240 | public length(): number {
241 | return this.children.reduce((memo, child) => {
242 | return memo + child.length();
243 | }, 0);
244 | }
245 |
246 | public moveChildren(targetParent: Parent, refNode?: Blot | null): void {
247 | this.children.forEach((child) => {
248 | targetParent.insertBefore(child, refNode);
249 | });
250 | }
251 |
252 | public optimize(context?: { [key: string]: any }): void {
253 | super.optimize(context);
254 | this.enforceAllowedChildren();
255 | if (this.uiNode != null && this.uiNode !== this.domNode.firstChild) {
256 | this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
257 | }
258 | if (this.children.length === 0) {
259 | if (this.statics.defaultChild != null) {
260 | const child = this.scroll.create(this.statics.defaultChild.blotName);
261 | this.appendChild(child);
262 | // TODO double check if necessary
263 | // child.optimize(context);
264 | } else {
265 | this.remove();
266 | }
267 | }
268 | }
269 |
270 | public path(index: number, inclusive = false): [Blot, number][] {
271 | const [child, offset] = this.children.find(index, inclusive);
272 | const position: [Blot, number][] = [[this, index]];
273 | if (child instanceof ParentBlot) {
274 | return position.concat(child.path(offset, inclusive));
275 | } else if (child != null) {
276 | position.push([child, offset]);
277 | }
278 | return position;
279 | }
280 |
281 | public removeChild(child: Blot): void {
282 | this.children.remove(child);
283 | }
284 |
285 | public replaceWith(name: string | Blot, value?: any): Blot {
286 | const replacement =
287 | typeof name === 'string' ? this.scroll.create(name, value) : name;
288 | if (replacement instanceof ParentBlot) {
289 | this.moveChildren(replacement);
290 | }
291 | return super.replaceWith(replacement);
292 | }
293 |
294 | public split(index: number, force = false): Blot | null {
295 | if (!force) {
296 | if (index === 0) {
297 | return this;
298 | }
299 | if (index === this.length()) {
300 | return this.next;
301 | }
302 | }
303 | const after = this.clone() as ParentBlot;
304 | if (this.parent) {
305 | this.parent.insertBefore(after, this.next || undefined);
306 | }
307 | this.children.forEachAt(index, this.length(), (child, offset, _length) => {
308 | const split = child.split(offset, force);
309 | if (split != null) {
310 | after.appendChild(split);
311 | }
312 | });
313 | return after;
314 | }
315 |
316 | public splitAfter(child: Blot): Parent {
317 | const after = this.clone() as ParentBlot;
318 | while (child.next != null) {
319 | after.appendChild(child.next);
320 | }
321 | if (this.parent) {
322 | this.parent.insertBefore(after, this.next || undefined);
323 | }
324 | return after;
325 | }
326 |
327 | public unwrap(): void {
328 | if (this.parent) {
329 | this.moveChildren(this.parent, this.next || undefined);
330 | }
331 | this.remove();
332 | }
333 |
334 | public update(
335 | mutations: MutationRecord[],
336 | _context: { [key: string]: any },
337 | ): void {
338 | const addedNodes: Node[] = [];
339 | const removedNodes: Node[] = [];
340 | mutations.forEach((mutation) => {
341 | if (mutation.target === this.domNode && mutation.type === 'childList') {
342 | addedNodes.push(...mutation.addedNodes);
343 | removedNodes.push(...mutation.removedNodes);
344 | }
345 | });
346 | removedNodes.forEach((node: Node) => {
347 | // Check node has actually been removed
348 | // One exception is Chrome does not immediately remove IFRAMEs
349 | // from DOM but MutationRecord is correct in its reported removal
350 | if (
351 | node.parentNode != null &&
352 | // @ts-expect-error Fix me later
353 | node.tagName !== 'IFRAME' &&
354 | document.body.compareDocumentPosition(node) &
355 | Node.DOCUMENT_POSITION_CONTAINED_BY
356 | ) {
357 | return;
358 | }
359 | const blot = this.scroll.find(node);
360 | if (blot == null) {
361 | return;
362 | }
363 | if (
364 | blot.domNode.parentNode == null ||
365 | blot.domNode.parentNode === this.domNode
366 | ) {
367 | blot.detach();
368 | }
369 | });
370 | addedNodes
371 | .filter((node) => {
372 | return node.parentNode === this.domNode && node !== this.uiNode;
373 | })
374 | .sort((a, b) => {
375 | if (a === b) {
376 | return 0;
377 | }
378 | if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) {
379 | return 1;
380 | }
381 | return -1;
382 | })
383 | .forEach((node) => {
384 | let refBlot: Blot | null = null;
385 | if (node.nextSibling != null) {
386 | refBlot = this.scroll.find(node.nextSibling);
387 | }
388 | const blot = makeAttachedBlot(node, this.scroll);
389 | if (blot.next !== refBlot || blot.next == null) {
390 | if (blot.parent != null) {
391 | blot.parent.removeChild(this);
392 | }
393 | this.insertBefore(blot, refBlot || undefined);
394 | }
395 | });
396 | this.enforceAllowedChildren();
397 | }
398 | }
399 |
400 | export default ParentBlot;
401 |
--------------------------------------------------------------------------------
/src/blot/abstract/shadow.ts:
--------------------------------------------------------------------------------
1 | import ParchmentError from '../../error.js';
2 | import Registry from '../../registry.js';
3 | import Scope from '../../scope.js';
4 | import type {
5 | Blot,
6 | BlotConstructor,
7 | Formattable,
8 | Parent,
9 | Root,
10 | } from './blot.js';
11 |
12 | class ShadowBlot implements Blot {
13 | public static blotName = 'abstract';
14 | public static className: string;
15 | public static requiredContainer: BlotConstructor;
16 | public static scope: Scope;
17 | public static tagName: string | string[];
18 |
19 | public static create(rawValue?: unknown): Node {
20 | if (this.tagName == null) {
21 | throw new ParchmentError('Blot definition missing tagName');
22 | }
23 | let node: HTMLElement;
24 | let value: string | number | undefined;
25 | if (Array.isArray(this.tagName)) {
26 | if (typeof rawValue === 'string') {
27 | value = rawValue.toUpperCase();
28 | if (parseInt(value, 10).toString() === value) {
29 | value = parseInt(value, 10);
30 | }
31 | } else if (typeof rawValue === 'number') {
32 | value = rawValue;
33 | }
34 | if (typeof value === 'number') {
35 | node = document.createElement(this.tagName[value - 1]);
36 | } else if (value && this.tagName.indexOf(value) > -1) {
37 | node = document.createElement(value);
38 | } else {
39 | node = document.createElement(this.tagName[0]);
40 | }
41 | } else {
42 | node = document.createElement(this.tagName);
43 | }
44 | if (this.className) {
45 | node.classList.add(this.className);
46 | }
47 | return node;
48 | }
49 |
50 | public prev: Blot | null;
51 | public next: Blot | null;
52 | // @ts-expect-error Fix me later
53 | public parent: Parent;
54 |
55 | // Hack for accessing inherited static methods
56 | get statics(): any {
57 | return this.constructor;
58 | }
59 | constructor(
60 | public scroll: Root,
61 | public domNode: Node,
62 | ) {
63 | Registry.blots.set(domNode, this);
64 | this.prev = null;
65 | this.next = null;
66 | }
67 |
68 | public attach(): void {
69 | // Nothing to do
70 | }
71 |
72 | public clone(): Blot {
73 | const domNode = this.domNode.cloneNode(false);
74 | return this.scroll.create(domNode);
75 | }
76 |
77 | public detach(): void {
78 | if (this.parent != null) {
79 | this.parent.removeChild(this);
80 | }
81 | Registry.blots.delete(this.domNode);
82 | }
83 |
84 | public deleteAt(index: number, length: number): void {
85 | const blot = this.isolate(index, length);
86 | blot.remove();
87 | }
88 |
89 | public formatAt(
90 | index: number,
91 | length: number,
92 | name: string,
93 | value: any,
94 | ): void {
95 | const blot = this.isolate(index, length);
96 | if (this.scroll.query(name, Scope.BLOT) != null && value) {
97 | blot.wrap(name, value);
98 | } else if (this.scroll.query(name, Scope.ATTRIBUTE) != null) {
99 | const parent = this.scroll.create(this.statics.scope) as Parent &
100 | Formattable;
101 | blot.wrap(parent);
102 | parent.format(name, value);
103 | }
104 | }
105 |
106 | public insertAt(index: number, value: string, def?: any): void {
107 | const blot =
108 | def == null
109 | ? this.scroll.create('text', value)
110 | : this.scroll.create(value, def);
111 | const ref = this.split(index);
112 | this.parent.insertBefore(blot, ref || undefined);
113 | }
114 |
115 | public isolate(index: number, length: number): Blot {
116 | const target = this.split(index);
117 | if (target == null) {
118 | throw new Error('Attempt to isolate at end');
119 | }
120 | target.split(length);
121 | return target;
122 | }
123 |
124 | public length(): number {
125 | return 1;
126 | }
127 |
128 | public offset(root: Blot = this.parent): number {
129 | if (this.parent == null || this === root) {
130 | return 0;
131 | }
132 | return this.parent.children.offset(this) + this.parent.offset(root);
133 | }
134 |
135 | public optimize(_context?: { [key: string]: any }): void {
136 | if (
137 | this.statics.requiredContainer &&
138 | !(this.parent instanceof this.statics.requiredContainer)
139 | ) {
140 | this.wrap(this.statics.requiredContainer.blotName);
141 | }
142 | }
143 |
144 | public remove(): void {
145 | if (this.domNode.parentNode != null) {
146 | this.domNode.parentNode.removeChild(this.domNode);
147 | }
148 | this.detach();
149 | }
150 |
151 | public replaceWith(name: string | Blot, value?: any): Blot {
152 | const replacement =
153 | typeof name === 'string' ? this.scroll.create(name, value) : name;
154 | if (this.parent != null) {
155 | this.parent.insertBefore(replacement, this.next || undefined);
156 | this.remove();
157 | }
158 | return replacement;
159 | }
160 |
161 | public split(index: number, _force?: boolean): Blot | null {
162 | return index === 0 ? this : this.next;
163 | }
164 |
165 | public update(
166 | _mutations: MutationRecord[],
167 | _context: { [key: string]: any },
168 | ): void {
169 | // Nothing to do by default
170 | }
171 |
172 | public wrap(name: string | Parent, value?: any): Parent {
173 | const wrapper =
174 | typeof name === 'string'
175 | ? (this.scroll.create(name, value) as Parent)
176 | : name;
177 | if (this.parent != null) {
178 | this.parent.insertBefore(wrapper, this.next || undefined);
179 | }
180 | if (typeof wrapper.appendChild !== 'function') {
181 | throw new ParchmentError(`Cannot wrap ${name}`);
182 | }
183 | wrapper.appendChild(this);
184 | return wrapper;
185 | }
186 | }
187 |
188 | export default ShadowBlot;
189 |
--------------------------------------------------------------------------------
/src/blot/block.ts:
--------------------------------------------------------------------------------
1 | import Attributor from '../attributor/attributor.js';
2 | import AttributorStore from '../attributor/store.js';
3 | import Scope from '../scope.js';
4 | import type {
5 | Blot,
6 | BlotConstructor,
7 | Formattable,
8 | Root,
9 | } from './abstract/blot.js';
10 | import LeafBlot from './abstract/leaf.js';
11 | import ParentBlot from './abstract/parent.js';
12 | import InlineBlot from './inline.js';
13 |
14 | class BlockBlot extends ParentBlot implements Formattable {
15 | public static blotName = 'block';
16 | public static scope = Scope.BLOCK_BLOT;
17 | public static tagName: string | string[] = 'P';
18 | public static allowedChildren: BlotConstructor[] = [
19 | InlineBlot,
20 | BlockBlot,
21 | LeafBlot,
22 | ];
23 |
24 | static create(value?: unknown) {
25 | return super.create(value) as HTMLElement;
26 | }
27 |
28 | public static formats(domNode: HTMLElement, scroll: Root): any {
29 | const match = scroll.query(BlockBlot.blotName);
30 | if (
31 | match != null &&
32 | domNode.tagName === (match as BlotConstructor).tagName
33 | ) {
34 | return undefined;
35 | } else if (typeof this.tagName === 'string') {
36 | return true;
37 | } else if (Array.isArray(this.tagName)) {
38 | return domNode.tagName.toLowerCase();
39 | }
40 | }
41 |
42 | protected attributes: AttributorStore;
43 |
44 | constructor(scroll: Root, domNode: Node) {
45 | super(scroll, domNode);
46 | this.attributes = new AttributorStore(this.domNode);
47 | }
48 |
49 | public format(name: string, value: any): void {
50 | const format = this.scroll.query(name, Scope.BLOCK);
51 | if (format == null) {
52 | return;
53 | } else if (format instanceof Attributor) {
54 | this.attributes.attribute(format, value);
55 | } else if (name === this.statics.blotName && !value) {
56 | this.replaceWith(BlockBlot.blotName);
57 | } else if (
58 | value &&
59 | (name !== this.statics.blotName || this.formats()[name] !== value)
60 | ) {
61 | this.replaceWith(name, value);
62 | }
63 | }
64 |
65 | public formats(): { [index: string]: any } {
66 | const formats = this.attributes.values();
67 | const format = this.statics.formats(this.domNode, this.scroll);
68 | if (format != null) {
69 | formats[this.statics.blotName] = format;
70 | }
71 | return formats;
72 | }
73 |
74 | public formatAt(
75 | index: number,
76 | length: number,
77 | name: string,
78 | value: any,
79 | ): void {
80 | if (this.scroll.query(name, Scope.BLOCK) != null) {
81 | this.format(name, value);
82 | } else {
83 | super.formatAt(index, length, name, value);
84 | }
85 | }
86 |
87 | public insertAt(index: number, value: string, def?: any): void {
88 | if (def == null || this.scroll.query(value, Scope.INLINE) != null) {
89 | // Insert text or inline
90 | super.insertAt(index, value, def);
91 | } else {
92 | const after = this.split(index);
93 | if (after != null) {
94 | const blot = this.scroll.create(value, def);
95 | after.parent.insertBefore(blot, after);
96 | } else {
97 | throw new Error('Attempt to insertAt after block boundaries');
98 | }
99 | }
100 | }
101 |
102 | public replaceWith(name: string | Blot, value?: any): Blot {
103 | const replacement = super.replaceWith(name, value) as BlockBlot;
104 | this.attributes.copy(replacement);
105 | return replacement;
106 | }
107 |
108 | public update(
109 | mutations: MutationRecord[],
110 | context: { [key: string]: any },
111 | ): void {
112 | super.update(mutations, context);
113 | const attributeChanged = mutations.some(
114 | (mutation) =>
115 | mutation.target === this.domNode && mutation.type === 'attributes',
116 | );
117 | if (attributeChanged) {
118 | this.attributes.build();
119 | }
120 | }
121 | }
122 |
123 | export default BlockBlot;
124 |
--------------------------------------------------------------------------------
/src/blot/embed.ts:
--------------------------------------------------------------------------------
1 | import type { Formattable, Root } from './abstract/blot.js';
2 | import LeafBlot from './abstract/leaf.js';
3 |
4 | class EmbedBlot extends LeafBlot implements Formattable {
5 | public static formats(_domNode: HTMLElement, _scroll: Root): any {
6 | return undefined;
7 | }
8 |
9 | public format(name: string, value: any): void {
10 | // super.formatAt wraps, which is what we want in general,
11 | // but this allows subclasses to overwrite for formats
12 | // that just apply to particular embeds
13 | super.formatAt(0, this.length(), name, value);
14 | }
15 |
16 | public formatAt(
17 | index: number,
18 | length: number,
19 | name: string,
20 | value: any,
21 | ): void {
22 | if (index === 0 && length === this.length()) {
23 | this.format(name, value);
24 | } else {
25 | super.formatAt(index, length, name, value);
26 | }
27 | }
28 |
29 | public formats(): { [index: string]: any } {
30 | return this.statics.formats(this.domNode, this.scroll);
31 | }
32 | }
33 |
34 | export default EmbedBlot;
35 |
--------------------------------------------------------------------------------
/src/blot/inline.ts:
--------------------------------------------------------------------------------
1 | import Attributor from '../attributor/attributor.js';
2 | import AttributorStore from '../attributor/store.js';
3 | import Scope from '../scope.js';
4 | import type {
5 | Blot,
6 | BlotConstructor,
7 | Formattable,
8 | Parent,
9 | Root,
10 | } from './abstract/blot.js';
11 | import LeafBlot from './abstract/leaf.js';
12 | import ParentBlot from './abstract/parent.js';
13 |
14 | // Shallow object comparison
15 | function isEqual(
16 | obj1: Record,
17 | obj2: Record,
18 | ): boolean {
19 | if (Object.keys(obj1).length !== Object.keys(obj2).length) {
20 | return false;
21 | }
22 | for (const prop in obj1) {
23 | if (obj1[prop] !== obj2[prop]) {
24 | return false;
25 | }
26 | }
27 | return true;
28 | }
29 |
30 | class InlineBlot extends ParentBlot implements Formattable {
31 | public static allowedChildren: BlotConstructor[] = [InlineBlot, LeafBlot];
32 | public static blotName = 'inline';
33 | public static scope = Scope.INLINE_BLOT;
34 | public static tagName: string | string[] = 'SPAN';
35 |
36 | static create(value?: unknown) {
37 | return super.create(value) as HTMLElement;
38 | }
39 |
40 | public static formats(domNode: HTMLElement, scroll: Root): any {
41 | const match = scroll.query(InlineBlot.blotName);
42 | if (
43 | match != null &&
44 | domNode.tagName === (match as BlotConstructor).tagName
45 | ) {
46 | return undefined;
47 | } else if (typeof this.tagName === 'string') {
48 | return true;
49 | } else if (Array.isArray(this.tagName)) {
50 | return domNode.tagName.toLowerCase();
51 | }
52 | return undefined;
53 | }
54 |
55 | protected attributes: AttributorStore;
56 |
57 | constructor(scroll: Root, domNode: Node) {
58 | super(scroll, domNode);
59 | this.attributes = new AttributorStore(this.domNode);
60 | }
61 |
62 | public format(name: string, value: any): void {
63 | if (name === this.statics.blotName && !value) {
64 | this.children.forEach((child) => {
65 | if (!(child instanceof InlineBlot)) {
66 | child = child.wrap(InlineBlot.blotName, true);
67 | }
68 | this.attributes.copy(child as InlineBlot);
69 | });
70 | this.unwrap();
71 | } else {
72 | const format = this.scroll.query(name, Scope.INLINE);
73 | if (format == null) {
74 | return;
75 | }
76 | if (format instanceof Attributor) {
77 | this.attributes.attribute(format, value);
78 | } else if (
79 | value &&
80 | (name !== this.statics.blotName || this.formats()[name] !== value)
81 | ) {
82 | this.replaceWith(name, value);
83 | }
84 | }
85 | }
86 |
87 | public formats(): { [index: string]: any } {
88 | const formats = this.attributes.values();
89 | const format = this.statics.formats(this.domNode, this.scroll);
90 | if (format != null) {
91 | formats[this.statics.blotName] = format;
92 | }
93 | return formats;
94 | }
95 |
96 | public formatAt(
97 | index: number,
98 | length: number,
99 | name: string,
100 | value: any,
101 | ): void {
102 | if (
103 | this.formats()[name] != null ||
104 | this.scroll.query(name, Scope.ATTRIBUTE)
105 | ) {
106 | const blot = this.isolate(index, length) as InlineBlot;
107 | blot.format(name, value);
108 | } else {
109 | super.formatAt(index, length, name, value);
110 | }
111 | }
112 |
113 | public optimize(context: { [key: string]: any }): void {
114 | super.optimize(context);
115 | const formats = this.formats();
116 | if (Object.keys(formats).length === 0) {
117 | return this.unwrap(); // unformatted span
118 | }
119 | const next = this.next;
120 | if (
121 | next instanceof InlineBlot &&
122 | next.prev === this &&
123 | isEqual(formats, next.formats())
124 | ) {
125 | next.moveChildren(this);
126 | next.remove();
127 | }
128 | }
129 |
130 | public replaceWith(name: string | Blot, value?: any): Blot {
131 | const replacement = super.replaceWith(name, value) as InlineBlot;
132 | this.attributes.copy(replacement);
133 | return replacement;
134 | }
135 |
136 | public update(
137 | mutations: MutationRecord[],
138 | context: { [key: string]: any },
139 | ): void {
140 | super.update(mutations, context);
141 | const attributeChanged = mutations.some(
142 | (mutation) =>
143 | mutation.target === this.domNode && mutation.type === 'attributes',
144 | );
145 | if (attributeChanged) {
146 | this.attributes.build();
147 | }
148 | }
149 |
150 | public wrap(name: string | Parent, value?: any): Parent {
151 | const wrapper = super.wrap(name, value);
152 | if (wrapper instanceof InlineBlot) {
153 | this.attributes.move(wrapper);
154 | }
155 | return wrapper;
156 | }
157 | }
158 |
159 | export default InlineBlot;
160 |
--------------------------------------------------------------------------------
/src/blot/scroll.ts:
--------------------------------------------------------------------------------
1 | import Registry, { type RegistryDefinition } from '../registry.js';
2 | import Scope from '../scope.js';
3 | import type { Blot, BlotConstructor, Root } from './abstract/blot.js';
4 | import ContainerBlot from './abstract/container.js';
5 | import ParentBlot from './abstract/parent.js';
6 | import BlockBlot from './block.js';
7 |
8 | const OBSERVER_CONFIG = {
9 | attributes: true,
10 | characterData: true,
11 | characterDataOldValue: true,
12 | childList: true,
13 | subtree: true,
14 | };
15 |
16 | const MAX_OPTIMIZE_ITERATIONS = 100;
17 |
18 | class ScrollBlot extends ParentBlot implements Root {
19 | public static blotName = 'scroll';
20 | public static defaultChild = BlockBlot;
21 | public static allowedChildren: BlotConstructor[] = [BlockBlot, ContainerBlot];
22 | public static scope = Scope.BLOCK_BLOT;
23 | public static tagName = 'DIV';
24 |
25 | public observer: MutationObserver;
26 |
27 | constructor(
28 | public registry: Registry,
29 | node: HTMLDivElement,
30 | ) {
31 | // @ts-expect-error scroll is the root with no parent
32 | super(null, node);
33 | this.scroll = this;
34 | this.build();
35 | this.observer = new MutationObserver((mutations: MutationRecord[]) => {
36 | this.update(mutations);
37 | });
38 | this.observer.observe(this.domNode, OBSERVER_CONFIG);
39 | this.attach();
40 | }
41 |
42 | public create(input: Node | string | Scope, value?: any): Blot {
43 | return this.registry.create(this, input, value);
44 | }
45 |
46 | public find(node: Node | null, bubble = false): Blot | null {
47 | const blot = this.registry.find(node, bubble);
48 | if (!blot) {
49 | return null;
50 | }
51 | if (blot.scroll === this) {
52 | return blot;
53 | }
54 | return bubble ? this.find(blot.scroll.domNode.parentNode, true) : null;
55 | }
56 |
57 | public query(
58 | query: string | Node | Scope,
59 | scope: Scope = Scope.ANY,
60 | ): RegistryDefinition | null {
61 | return this.registry.query(query, scope);
62 | }
63 |
64 | public register(...definitions: RegistryDefinition[]) {
65 | return this.registry.register(...definitions);
66 | }
67 |
68 | public build(): void {
69 | if (this.scroll == null) {
70 | return;
71 | }
72 | super.build();
73 | }
74 |
75 | public detach(): void {
76 | super.detach();
77 | this.observer.disconnect();
78 | }
79 |
80 | public deleteAt(index: number, length: number): void {
81 | this.update();
82 | if (index === 0 && length === this.length()) {
83 | this.children.forEach((child) => {
84 | child.remove();
85 | });
86 | } else {
87 | super.deleteAt(index, length);
88 | }
89 | }
90 |
91 | public formatAt(
92 | index: number,
93 | length: number,
94 | name: string,
95 | value: any,
96 | ): void {
97 | this.update();
98 | super.formatAt(index, length, name, value);
99 | }
100 |
101 | public insertAt(index: number, value: string, def?: any): void {
102 | this.update();
103 | super.insertAt(index, value, def);
104 | }
105 |
106 | public optimize(context?: { [key: string]: any }): void;
107 | public optimize(
108 | mutations: MutationRecord[],
109 | context: { [key: string]: any },
110 | ): void;
111 | public optimize(mutations: any = [], context: any = {}): void {
112 | super.optimize(context);
113 | const mutationsMap = context.mutationsMap || new WeakMap();
114 | // We must modify mutations directly, cannot make copy and then modify
115 | let records = Array.from(this.observer.takeRecords());
116 | // Array.push currently seems to be implemented by a non-tail recursive function
117 | // so we cannot just mutations.push.apply(mutations, this.observer.takeRecords());
118 | while (records.length > 0) {
119 | mutations.push(records.pop());
120 | }
121 | const mark = (blot: Blot | null, markParent = true): void => {
122 | if (blot == null || blot === this) {
123 | return;
124 | }
125 | if (blot.domNode.parentNode == null) {
126 | return;
127 | }
128 | if (!mutationsMap.has(blot.domNode)) {
129 | mutationsMap.set(blot.domNode, []);
130 | }
131 | if (markParent) {
132 | mark(blot.parent);
133 | }
134 | };
135 | const optimize = (blot: Blot): void => {
136 | // Post-order traversal
137 | if (!mutationsMap.has(blot.domNode)) {
138 | return;
139 | }
140 | if (blot instanceof ParentBlot) {
141 | blot.children.forEach(optimize);
142 | }
143 | mutationsMap.delete(blot.domNode);
144 | blot.optimize(context);
145 | };
146 | let remaining = mutations;
147 | for (let i = 0; remaining.length > 0; i += 1) {
148 | if (i >= MAX_OPTIMIZE_ITERATIONS) {
149 | throw new Error('[Parchment] Maximum optimize iterations reached');
150 | }
151 | remaining.forEach((mutation: MutationRecord) => {
152 | const blot = this.find(mutation.target, true);
153 | if (blot == null) {
154 | return;
155 | }
156 | if (blot.domNode === mutation.target) {
157 | if (mutation.type === 'childList') {
158 | mark(this.find(mutation.previousSibling, false));
159 | Array.from(mutation.addedNodes).forEach((node: Node) => {
160 | const child = this.find(node, false);
161 | mark(child, false);
162 | if (child instanceof ParentBlot) {
163 | child.children.forEach((grandChild: Blot) => {
164 | mark(grandChild, false);
165 | });
166 | }
167 | });
168 | } else if (mutation.type === 'attributes') {
169 | mark(blot.prev);
170 | }
171 | }
172 | mark(blot);
173 | });
174 | this.children.forEach(optimize);
175 | remaining = Array.from(this.observer.takeRecords());
176 | records = remaining.slice();
177 | while (records.length > 0) {
178 | mutations.push(records.pop());
179 | }
180 | }
181 | }
182 |
183 | public update(
184 | mutations?: MutationRecord[],
185 | context: { [key: string]: any } = {},
186 | ): void {
187 | mutations = mutations || this.observer.takeRecords();
188 | const mutationsMap = new WeakMap();
189 | mutations
190 | .map((mutation: MutationRecord) => {
191 | const blot = this.find(mutation.target, true);
192 | if (blot == null) {
193 | return null;
194 | }
195 | if (mutationsMap.has(blot.domNode)) {
196 | mutationsMap.get(blot.domNode).push(mutation);
197 | return null;
198 | } else {
199 | mutationsMap.set(blot.domNode, [mutation]);
200 | return blot;
201 | }
202 | })
203 | .forEach((blot: Blot | null) => {
204 | if (blot != null && blot !== this && mutationsMap.has(blot.domNode)) {
205 | blot.update(mutationsMap.get(blot.domNode) || [], context);
206 | }
207 | });
208 | context.mutationsMap = mutationsMap;
209 | if (mutationsMap.has(this.domNode)) {
210 | super.update(mutationsMap.get(this.domNode), context);
211 | }
212 | this.optimize(mutations, context);
213 | }
214 | }
215 |
216 | export default ScrollBlot;
217 |
--------------------------------------------------------------------------------
/src/blot/text.ts:
--------------------------------------------------------------------------------
1 | import Scope from '../scope.js';
2 | import type { Blot, Leaf, Root } from './abstract/blot.js';
3 | import LeafBlot from './abstract/leaf.js';
4 |
5 | class TextBlot extends LeafBlot implements Leaf {
6 | public static readonly blotName = 'text';
7 | public static scope = Scope.INLINE_BLOT;
8 |
9 | public static create(value: string): Text {
10 | return document.createTextNode(value);
11 | }
12 |
13 | public static value(domNode: Text): string {
14 | return domNode.data;
15 | }
16 |
17 | public domNode!: Text;
18 | protected text: string;
19 |
20 | constructor(scroll: Root, node: Node) {
21 | super(scroll, node);
22 | this.text = this.statics.value(this.domNode);
23 | }
24 |
25 | public deleteAt(index: number, length: number): void {
26 | this.domNode.data = this.text =
27 | this.text.slice(0, index) + this.text.slice(index + length);
28 | }
29 |
30 | public index(node: Node, offset: number): number {
31 | if (this.domNode === node) {
32 | return offset;
33 | }
34 | return -1;
35 | }
36 |
37 | public insertAt(index: number, value: string, def?: any): void {
38 | if (def == null) {
39 | this.text = this.text.slice(0, index) + value + this.text.slice(index);
40 | this.domNode.data = this.text;
41 | } else {
42 | super.insertAt(index, value, def);
43 | }
44 | }
45 |
46 | public length(): number {
47 | return this.text.length;
48 | }
49 |
50 | public optimize(context: { [key: string]: any }): void {
51 | super.optimize(context);
52 | this.text = this.statics.value(this.domNode);
53 | if (this.text.length === 0) {
54 | this.remove();
55 | } else if (this.next instanceof TextBlot && this.next.prev === this) {
56 | this.insertAt(this.length(), (this.next as TextBlot).value());
57 | this.next.remove();
58 | }
59 | }
60 |
61 | public position(index: number, _inclusive = false): [Node, number] {
62 | return [this.domNode, index];
63 | }
64 |
65 | public split(index: number, force = false): Blot | null {
66 | if (!force) {
67 | if (index === 0) {
68 | return this;
69 | }
70 | if (index === this.length()) {
71 | return this.next;
72 | }
73 | }
74 | const after = this.scroll.create(this.domNode.splitText(index));
75 | this.parent.insertBefore(after, this.next || undefined);
76 | this.text = this.statics.value(this.domNode);
77 | return after;
78 | }
79 |
80 | public update(
81 | mutations: MutationRecord[],
82 | _context: { [key: string]: any },
83 | ): void {
84 | if (
85 | mutations.some((mutation) => {
86 | return (
87 | mutation.type === 'characterData' && mutation.target === this.domNode
88 | );
89 | })
90 | ) {
91 | this.text = this.statics.value(this.domNode);
92 | }
93 | }
94 |
95 | public value(): string {
96 | return this.text;
97 | }
98 | }
99 |
100 | export default TextBlot;
101 |
--------------------------------------------------------------------------------
/src/collection/linked-list.ts:
--------------------------------------------------------------------------------
1 | import type LinkedNode from './linked-node.js';
2 |
3 | class LinkedList {
4 | public head: T | null;
5 | public tail: T | null;
6 | public length: number;
7 |
8 | constructor() {
9 | this.head = null;
10 | this.tail = null;
11 | this.length = 0;
12 | }
13 |
14 | public append(...nodes: T[]): void {
15 | this.insertBefore(nodes[0], null);
16 | if (nodes.length > 1) {
17 | const rest = nodes.slice(1);
18 | this.append(...rest);
19 | }
20 | }
21 |
22 | public at(index: number): T | null {
23 | const next = this.iterator();
24 | let cur = next();
25 | while (cur && index > 0) {
26 | index -= 1;
27 | cur = next();
28 | }
29 | return cur;
30 | }
31 |
32 | public contains(node: T): boolean {
33 | const next = this.iterator();
34 | let cur = next();
35 | while (cur) {
36 | if (cur === node) {
37 | return true;
38 | }
39 | cur = next();
40 | }
41 | return false;
42 | }
43 |
44 | public indexOf(node: T): number {
45 | const next = this.iterator();
46 | let cur = next();
47 | let index = 0;
48 | while (cur) {
49 | if (cur === node) {
50 | return index;
51 | }
52 | index += 1;
53 | cur = next();
54 | }
55 | return -1;
56 | }
57 |
58 | public insertBefore(node: T | null, refNode: T | null): void {
59 | if (node == null) {
60 | return;
61 | }
62 | this.remove(node);
63 | node.next = refNode;
64 | if (refNode != null) {
65 | node.prev = refNode.prev;
66 | if (refNode.prev != null) {
67 | refNode.prev.next = node;
68 | }
69 | refNode.prev = node;
70 | if (refNode === this.head) {
71 | this.head = node;
72 | }
73 | } else if (this.tail != null) {
74 | this.tail.next = node;
75 | node.prev = this.tail;
76 | this.tail = node;
77 | } else {
78 | node.prev = null;
79 | this.head = this.tail = node;
80 | }
81 | this.length += 1;
82 | }
83 |
84 | public offset(target: T): number {
85 | let index = 0;
86 | let cur = this.head;
87 | while (cur != null) {
88 | if (cur === target) {
89 | return index;
90 | }
91 | index += cur.length();
92 | cur = cur.next as T;
93 | }
94 | return -1;
95 | }
96 |
97 | public remove(node: T): void {
98 | if (!this.contains(node)) {
99 | return;
100 | }
101 | if (node.prev != null) {
102 | node.prev.next = node.next;
103 | }
104 | if (node.next != null) {
105 | node.next.prev = node.prev;
106 | }
107 | if (node === this.head) {
108 | this.head = node.next as T;
109 | }
110 | if (node === this.tail) {
111 | this.tail = node.prev as T;
112 | }
113 | this.length -= 1;
114 | }
115 |
116 | public iterator(curNode: T | null = this.head): () => T | null {
117 | // TODO use yield when we can
118 | return (): T | null => {
119 | const ret = curNode;
120 | if (curNode != null) {
121 | curNode = curNode.next as T;
122 | }
123 | return ret;
124 | };
125 | }
126 |
127 | public find(index: number, inclusive = false): [T | null, number] {
128 | const next = this.iterator();
129 | let cur = next();
130 | while (cur) {
131 | const length = cur.length();
132 | if (
133 | index < length ||
134 | (inclusive &&
135 | index === length &&
136 | (cur.next == null || cur.next.length() !== 0))
137 | ) {
138 | return [cur, index];
139 | }
140 | index -= length;
141 | cur = next();
142 | }
143 | return [null, 0];
144 | }
145 |
146 | public forEach(callback: (cur: T) => void): void {
147 | const next = this.iterator();
148 | let cur = next();
149 | while (cur) {
150 | callback(cur);
151 | cur = next();
152 | }
153 | }
154 |
155 | public forEachAt(
156 | index: number,
157 | length: number,
158 | callback: (cur: T, offset: number, length: number) => void,
159 | ): void {
160 | if (length <= 0) {
161 | return;
162 | }
163 | const [startNode, offset] = this.find(index);
164 | let curIndex = index - offset;
165 | const next = this.iterator(startNode);
166 | let cur = next();
167 | while (cur && curIndex < index + length) {
168 | const curLength = cur.length();
169 | if (index > curIndex) {
170 | callback(
171 | cur,
172 | index - curIndex,
173 | Math.min(length, curIndex + curLength - index),
174 | );
175 | } else {
176 | callback(cur, 0, Math.min(curLength, index + length - curIndex));
177 | }
178 | curIndex += curLength;
179 | cur = next();
180 | }
181 | }
182 |
183 | public map(callback: (cur: T) => any): any[] {
184 | return this.reduce((memo: T[], cur: T) => {
185 | memo.push(callback(cur));
186 | return memo;
187 | }, []);
188 | }
189 |
190 | public reduce(callback: (memo: M, cur: T) => M, memo: M): M {
191 | const next = this.iterator();
192 | let cur = next();
193 | while (cur) {
194 | memo = callback(memo, cur);
195 | cur = next();
196 | }
197 | return memo;
198 | }
199 | }
200 |
201 | export default LinkedList;
202 |
--------------------------------------------------------------------------------
/src/collection/linked-node.ts:
--------------------------------------------------------------------------------
1 | interface LinkedNode {
2 | prev: LinkedNode | null;
3 | next: LinkedNode | null;
4 |
5 | length(): number;
6 | }
7 |
8 | export type { LinkedNode as default };
9 |
--------------------------------------------------------------------------------
/src/error.ts:
--------------------------------------------------------------------------------
1 | export default class ParchmentError extends Error {
2 | public message: string;
3 | public name: string;
4 | public stack!: string;
5 |
6 | constructor(message: string) {
7 | message = '[Parchment] ' + message;
8 | super(message);
9 | this.message = message;
10 | this.name = this.constructor.name;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/parchment.ts:
--------------------------------------------------------------------------------
1 | import ContainerBlot from './blot/abstract/container.js';
2 | import LeafBlot from './blot/abstract/leaf.js';
3 | import ParentBlot from './blot/abstract/parent.js';
4 |
5 | import BlockBlot from './blot/block.js';
6 | import EmbedBlot from './blot/embed.js';
7 | import InlineBlot from './blot/inline.js';
8 | import ScrollBlot from './blot/scroll.js';
9 | import TextBlot from './blot/text.js';
10 |
11 | import Attributor from './attributor/attributor.js';
12 | import ClassAttributor from './attributor/class.js';
13 | import AttributorStore from './attributor/store.js';
14 | import StyleAttributor from './attributor/style.js';
15 |
16 | import Registry from './registry.js';
17 | import Scope from './scope.js';
18 |
19 | export {
20 | ParentBlot,
21 | ContainerBlot,
22 | LeafBlot,
23 | EmbedBlot,
24 | ScrollBlot,
25 | BlockBlot,
26 | InlineBlot,
27 | TextBlot,
28 | Attributor,
29 | ClassAttributor,
30 | StyleAttributor,
31 | AttributorStore,
32 | Registry,
33 | Scope,
34 | };
35 |
36 | export type { RegistryInterface, RegistryDefinition } from './registry.js';
37 | export type { default as ShadowBlot } from './blot/abstract/shadow.js';
38 | export type { default as LinkedList } from './collection/linked-list.js';
39 | export type { default as LinkedNode } from './collection/linked-node.js';
40 | export type { AttributorOptions } from './attributor/attributor.js';
41 | export type {
42 | Blot,
43 | BlotConstructor,
44 | Formattable,
45 | Leaf,
46 | Parent,
47 | Root,
48 | } from './blot/abstract/blot.js';
49 |
--------------------------------------------------------------------------------
/src/registry.ts:
--------------------------------------------------------------------------------
1 | import Attributor from './attributor/attributor.js';
2 | import {
3 | type Blot,
4 | type BlotConstructor,
5 | type Root,
6 | } from './blot/abstract/blot.js';
7 | import ParchmentError from './error.js';
8 | import Scope from './scope.js';
9 |
10 | export type RegistryDefinition = Attributor | BlotConstructor;
11 |
12 | export interface RegistryInterface {
13 | create(scroll: Root, input: Node | string | Scope, value?: any): Blot;
14 | query(query: string | Node | Scope, scope: Scope): RegistryDefinition | null;
15 | register(...definitions: any[]): any;
16 | }
17 |
18 | export default class Registry implements RegistryInterface {
19 | public static blots = new WeakMap();
20 |
21 | public static find(node?: Node | null, bubble = false): Blot | null {
22 | if (node == null) {
23 | return null;
24 | }
25 | if (this.blots.has(node)) {
26 | return this.blots.get(node) || null;
27 | }
28 | if (bubble) {
29 | let parentNode: Node | null = null;
30 | try {
31 | parentNode = node.parentNode;
32 | } catch (err) {
33 | // Probably hit a permission denied error.
34 | // A known case is in Firefox, event targets can be anonymous DIVs
35 | // inside an input element.
36 | // https://bugzilla.mozilla.org/show_bug.cgi?id=208427
37 | return null;
38 | }
39 | return this.find(parentNode, bubble);
40 | }
41 | return null;
42 | }
43 |
44 | private attributes: { [key: string]: Attributor } = {};
45 | private classes: { [key: string]: BlotConstructor } = {};
46 | private tags: { [key: string]: BlotConstructor } = {};
47 | private types: { [key: string]: RegistryDefinition } = {};
48 |
49 | public create(scroll: Root, input: Node | string | Scope, value?: any): Blot {
50 | const match = this.query(input);
51 | if (match == null) {
52 | throw new ParchmentError(`Unable to create ${input} blot`);
53 | }
54 | const blotClass = match as BlotConstructor;
55 | const node =
56 | // @ts-expect-error Fix me later
57 | input instanceof Node || input.nodeType === Node.TEXT_NODE
58 | ? input
59 | : blotClass.create(value);
60 |
61 | const blot = new blotClass(scroll, node as Node, value);
62 | Registry.blots.set(blot.domNode, blot);
63 | return blot;
64 | }
65 |
66 | public find(node: Node | null, bubble = false): Blot | null {
67 | return Registry.find(node, bubble);
68 | }
69 |
70 | public query(
71 | query: string | Node | Scope,
72 | scope: Scope = Scope.ANY,
73 | ): RegistryDefinition | null {
74 | let match;
75 | if (typeof query === 'string') {
76 | match = this.types[query] || this.attributes[query];
77 | // @ts-expect-error Fix me later
78 | } else if (query instanceof Text || query.nodeType === Node.TEXT_NODE) {
79 | match = this.types.text;
80 | } else if (typeof query === 'number') {
81 | if (query & Scope.LEVEL & Scope.BLOCK) {
82 | match = this.types.block;
83 | } else if (query & Scope.LEVEL & Scope.INLINE) {
84 | match = this.types.inline;
85 | }
86 | } else if (query instanceof Element) {
87 | const names = (query.getAttribute('class') || '').split(/\s+/);
88 | names.some((name) => {
89 | match = this.classes[name];
90 | if (match) {
91 | return true;
92 | }
93 | return false;
94 | });
95 | match = match || this.tags[query.tagName];
96 | }
97 | if (match == null) {
98 | return null;
99 | }
100 | if (
101 | 'scope' in match &&
102 | scope & Scope.LEVEL & match.scope &&
103 | scope & Scope.TYPE & match.scope
104 | ) {
105 | return match;
106 | }
107 | return null;
108 | }
109 |
110 | public register(...definitions: RegistryDefinition[]): RegistryDefinition[] {
111 | return definitions.map((definition) => {
112 | const isBlot = 'blotName' in definition;
113 | const isAttr = 'attrName' in definition;
114 | if (!isBlot && !isAttr) {
115 | throw new ParchmentError('Invalid definition');
116 | } else if (isBlot && definition.blotName === 'abstract') {
117 | throw new ParchmentError('Cannot register abstract class');
118 | }
119 | const key = isBlot
120 | ? definition.blotName
121 | : isAttr
122 | ? definition.attrName
123 | : (undefined as never); // already handled by above checks
124 | this.types[key] = definition;
125 |
126 | if (isAttr) {
127 | if (typeof definition.keyName === 'string') {
128 | this.attributes[definition.keyName] = definition;
129 | }
130 | } else if (isBlot) {
131 | if (definition.className) {
132 | this.classes[definition.className] = definition;
133 | }
134 | if (definition.tagName) {
135 | if (Array.isArray(definition.tagName)) {
136 | definition.tagName = definition.tagName.map((tagName: string) => {
137 | return tagName.toUpperCase();
138 | });
139 | } else {
140 | definition.tagName = definition.tagName.toUpperCase();
141 | }
142 | const tagNames = Array.isArray(definition.tagName)
143 | ? definition.tagName
144 | : [definition.tagName];
145 | tagNames.forEach((tag: string) => {
146 | if (this.tags[tag] == null || definition.className == null) {
147 | this.tags[tag] = definition;
148 | }
149 | });
150 | }
151 | }
152 | return definition;
153 | });
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/src/scope.ts:
--------------------------------------------------------------------------------
1 | enum Scope {
2 | TYPE = (1 << 2) - 1, // 0011 Lower two bits
3 | LEVEL = ((1 << 2) - 1) << 2, // 1100 Higher two bits
4 |
5 | ATTRIBUTE = (1 << 0) | LEVEL, // 1101
6 | BLOT = (1 << 1) | LEVEL, // 1110
7 | INLINE = (1 << 2) | TYPE, // 0111
8 | BLOCK = (1 << 3) | TYPE, // 1011
9 |
10 | BLOCK_BLOT = BLOCK & BLOT, // 1010
11 | INLINE_BLOT = INLINE & BLOT, // 0110
12 | BLOCK_ATTRIBUTE = BLOCK & ATTRIBUTE, // 1001
13 | INLINE_ATTRIBUTE = INLINE & ATTRIBUTE, // 0101
14 |
15 | ANY = TYPE | LEVEL,
16 | }
17 |
18 | export default Scope;
19 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/attributor.ts:
--------------------------------------------------------------------------------
1 | import Attributor from '../../../src/attributor/attributor.js';
2 | import ClassAttributor from '../../../src/attributor/class.js';
3 | import StyleAttributor from '../../../src/attributor/style.js';
4 | import Scope from '../../../src/scope.js';
5 |
6 | export const Color = new StyleAttributor('color', 'color', {
7 | scope: Scope.INLINE_ATTRIBUTE,
8 | });
9 |
10 | export const Size = new StyleAttributor('size', 'font-size', {
11 | scope: Scope.INLINE_ATTRIBUTE,
12 | });
13 |
14 | export const Family = new StyleAttributor('family', 'font-family', {
15 | scope: Scope.INLINE_ATTRIBUTE,
16 | whitelist: ['Arial', 'Times New Roman'],
17 | });
18 |
19 | export const Id = new Attributor('id', 'id');
20 |
21 | export const Align = new StyleAttributor('align', 'text-align', {
22 | scope: Scope.BLOCK_ATTRIBUTE,
23 | whitelist: ['right', 'center'], // exclude justify to test valid but missing from whitelist
24 | });
25 |
26 | export const Indent = new ClassAttributor('indent', 'indent', {
27 | scope: Scope.BLOCK_ATTRIBUTE,
28 | });
29 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/block.ts:
--------------------------------------------------------------------------------
1 | import BlockBlot from '../../../src/blot/block.js';
2 |
3 | export class HeaderBlot extends BlockBlot {
4 | static readonly blotName = 'header';
5 | static tagName = ['h1', 'h2'];
6 | static create(value?: number | string) {
7 | return super.create(value) as HTMLHeadingElement;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/break.ts:
--------------------------------------------------------------------------------
1 | import EmbedBlot from '../../../src/blot/embed.js';
2 |
3 | export class BreakBlot extends EmbedBlot {
4 | static readonly blotName = 'break';
5 | static tagName = 'br';
6 | }
7 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/embed.ts:
--------------------------------------------------------------------------------
1 | import EmbedBlot from '../../../src/blot/embed.js';
2 | import Scope from '../../../src/scope.js';
3 |
4 | export class ImageBlot extends EmbedBlot {
5 | declare domNode: HTMLImageElement;
6 | static readonly blotName = 'image';
7 | static tagName = 'IMG';
8 | static create(value: string) {
9 | const node = super.create(value) as HTMLElement;
10 | if (typeof value === 'string') {
11 | node.setAttribute('src', value);
12 | }
13 | return node;
14 | }
15 |
16 | static value(domNode: HTMLImageElement) {
17 | return domNode.getAttribute('src');
18 | }
19 |
20 | static formats(domNode: HTMLImageElement) {
21 | if (domNode.hasAttribute('alt')) {
22 | return { alt: domNode.getAttribute('alt') };
23 | }
24 | return undefined;
25 | }
26 |
27 | format(name: string, value: string) {
28 | if (name === 'alt') {
29 | this.domNode.setAttribute(name, value);
30 | } else {
31 | super.format(name, value);
32 | }
33 | }
34 | }
35 |
36 | export class VideoBlot extends EmbedBlot {
37 | declare domNode: HTMLVideoElement;
38 | static scope = Scope.BLOCK_BLOT;
39 | static readonly blotName = 'video';
40 | static tagName = 'VIDEO';
41 | static create(value: string) {
42 | const node = super.create(value) as HTMLVideoElement;
43 | if (typeof value === 'string') {
44 | node.setAttribute('src', value);
45 | }
46 | return node;
47 | }
48 |
49 | static formats(domNode: HTMLVideoElement) {
50 | const formats: Partial<{ height: string; width: string }> = {};
51 | const height = domNode.getAttribute('height');
52 | const width = domNode.getAttribute('width');
53 | height && (formats.height = height);
54 | width && (formats.width = width);
55 | return formats;
56 | }
57 |
58 | static value(domNode: HTMLVideoElement) {
59 | return domNode.getAttribute('src');
60 | }
61 |
62 | format(name: string, value: unknown) {
63 | if (name === 'height' || name === 'width') {
64 | if (value) {
65 | this.domNode.setAttribute(name, value.toString());
66 | } else {
67 | this.domNode.removeAttribute(name);
68 | }
69 | } else {
70 | super.format(name, value);
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/inline.ts:
--------------------------------------------------------------------------------
1 | import InlineBlot from '../../../src/blot/inline.js';
2 |
3 | export class AuthorBlot extends InlineBlot {
4 | static readonly blotName = 'author';
5 | static className = 'author-blot';
6 | }
7 |
8 | export class BoldBlot extends InlineBlot {
9 | static readonly blotName = 'bold';
10 | static tagName = 'strong';
11 | }
12 |
13 | export class ItalicBlot extends InlineBlot {
14 | static readonly blotName = 'italic';
15 | static tagName = 'em';
16 | }
17 |
18 | export class ScriptBlot extends InlineBlot {
19 | static readonly blotName = 'script';
20 | static tagName = ['sup', 'sub'];
21 | }
22 |
--------------------------------------------------------------------------------
/tests/__helpers__/registry/list.ts:
--------------------------------------------------------------------------------
1 | import ContainerBlot from '../../../src/blot/abstract/container.js';
2 | import BlockBlot from '../../../src/blot/block.js';
3 |
4 | export class ListItem extends BlockBlot {
5 | static readonly blotName = 'list';
6 | static tagName = 'LI';
7 | }
8 |
9 | export class ListContainer extends ContainerBlot {
10 | static readonly blotName = 'list-container';
11 | static tagName = 'OL';
12 | static allowedChildren = [ListItem];
13 | }
14 |
15 | // Can only define outside of ListItem class due to used-before-declaration error
16 | ListItem.requiredContainer = ListContainer;
17 |
--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
1 | import { beforeEach } from 'vitest';
2 |
3 | import {
4 | Registry,
5 | ScrollBlot,
6 | BlockBlot,
7 | InlineBlot,
8 | TextBlot,
9 | type BlotConstructor,
10 | } from '../src/parchment.js';
11 | import {
12 | AuthorBlot,
13 | BoldBlot,
14 | ItalicBlot,
15 | ScriptBlot,
16 | } from './__helpers__/registry/inline.js';
17 | import {
18 | Align,
19 | Color,
20 | Family,
21 | Id,
22 | Indent,
23 | Size,
24 | } from './__helpers__/registry/attributor.js';
25 | import { HeaderBlot } from './__helpers__/registry/block.js';
26 | import { ImageBlot, VideoBlot } from './__helpers__/registry/embed.js';
27 | import { ListContainer, ListItem } from './__helpers__/registry/list.js';
28 | import { BreakBlot } from './__helpers__/registry/break.js';
29 |
30 | const getTestRegistry = () => {
31 | const reg = new Registry();
32 |
33 | reg.register(ScrollBlot as unknown as BlotConstructor);
34 | reg.register(BlockBlot);
35 | reg.register(InlineBlot);
36 | reg.register(TextBlot);
37 | reg.register(AuthorBlot, BoldBlot, ItalicBlot, ScriptBlot);
38 |
39 | reg.register(Color, Size, Family, Id, Align, Indent);
40 | reg.register(HeaderBlot);
41 | reg.register(ImageBlot, VideoBlot);
42 | reg.register(ListItem, ListContainer);
43 | reg.register(BreakBlot);
44 |
45 | return reg;
46 | };
47 |
48 | type TestContext = {
49 | container: HTMLElement;
50 | scroll: ScrollBlot;
51 | registry: Registry;
52 | };
53 |
54 | export const setupContextBeforeEach = () => {
55 | const ctx = {} as TestContext;
56 | beforeEach(() => {
57 | const container = document.createElement('div');
58 | const registry = getTestRegistry();
59 | const scroll = new ScrollBlot(registry, container);
60 | ctx.container = container;
61 | ctx.scroll = scroll;
62 | ctx.registry = registry;
63 | });
64 | return ctx;
65 | };
66 |
--------------------------------------------------------------------------------
/tests/types/attributor.test-d.ts:
--------------------------------------------------------------------------------
1 | import { assertType } from 'vitest';
2 | import { ClassAttributor } from '../../src/parchment.js';
3 |
4 | class IndentAttributor extends ClassAttributor {
5 | value(node: HTMLElement) {
6 | return parseInt(super.value(node), 10) || undefined;
7 | }
8 | }
9 |
10 | assertType(
11 | new IndentAttributor('indent', 'indent').value(document.createElement('div')),
12 | );
13 |
--------------------------------------------------------------------------------
/tests/types/parent.test-d.ts:
--------------------------------------------------------------------------------
1 | import { assertType } from 'vitest';
2 | import {
3 | type Blot,
4 | EmbedBlot,
5 | Registry,
6 | ScrollBlot,
7 | ParentBlot,
8 | } from '../../src/parchment.js';
9 |
10 | const registry = new Registry();
11 | const root = document.createElement('div');
12 | const scroll = new ScrollBlot(registry, root);
13 |
14 | // ParentBlot#descendant()
15 | {
16 | const parent = new ParentBlot(scroll, document.createElement('div'));
17 | assertType<[EmbedBlot | null, number]>(parent.descendant(EmbedBlot, 12));
18 | assertType<[Blot | null, number]>(parent.descendant(() => true, 12));
19 | }
20 |
21 | // ParentBlot#descendants()
22 | {
23 | const parent = new ParentBlot(scroll, document.createElement('div'));
24 | assertType(parent.descendants(EmbedBlot));
25 | assertType(parent.descendants(EmbedBlot, 12));
26 | assertType(parent.descendants(EmbedBlot, 12, 2));
27 | assertType(parent.descendants(() => true));
28 | assertType(parent.descendants(() => true, 12));
29 | assertType(parent.descendants(() => true, 12, 2));
30 | }
31 |
--------------------------------------------------------------------------------
/tests/unit/attributor.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import type {
3 | Attributor,
4 | BlockBlot,
5 | Formattable,
6 | InlineBlot,
7 | } from '../../src/parchment.js';
8 | import type { HeaderBlot } from '../__helpers__/registry/block.js';
9 | import type { BoldBlot } from '../__helpers__/registry/inline.js';
10 | import { setupContextBeforeEach } from '../setup.js';
11 |
12 | describe('Attributor', function () {
13 | const ctx = setupContextBeforeEach();
14 |
15 | it('build', function () {
16 | const blot = ctx.scroll.create('inline') as InlineBlot;
17 | blot.domNode.style.color = 'red';
18 | blot.domNode.style.fontSize = '24px';
19 | blot.domNode.id = 'blot-test';
20 | blot.domNode.classList.add('indent-2');
21 | // Use bracket notation to access private fields as escape hatch
22 | // https://github.com/microsoft/TypeScript/issues/19335
23 | blot['attributes'].build();
24 | expect(Object.keys(blot['attributes']['attributes']).sort()).toEqual(
25 | ['color', 'size', 'id', 'indent'].sort(),
26 | );
27 | });
28 |
29 | it('add to inline', function () {
30 | const container = ctx.scroll.create('block') as BlockBlot;
31 | const boldBlot = ctx.scroll.create('bold') as BoldBlot;
32 | container.appendChild(boldBlot);
33 | boldBlot.format('id', 'test-add');
34 | expect(boldBlot.domNode.id).toEqual('test-add');
35 | });
36 |
37 | it('add multiple', function () {
38 | const node = document.createElement('p');
39 | node.innerHTML = '0';
40 | const container = ctx.scroll.create(node);
41 | container.formatAt(0, 1, 'color', 'red');
42 | container.formatAt(0, 1, 'size', '18px');
43 | expect(node.innerHTML).toEqual(
44 | '0',
45 | );
46 | });
47 |
48 | it('add to text', function () {
49 | const container = ctx.scroll.create('block') as BlockBlot;
50 | const textBlot = ctx.scroll.create('text', 'Test');
51 | container.appendChild(textBlot);
52 | textBlot.formatAt(0, 4, 'color', 'red');
53 | expect(textBlot.domNode.parentElement?.style.color).toEqual('red');
54 | });
55 |
56 | it('add existing style', function () {
57 | const boldBlot = ctx.scroll.create('bold') as BoldBlot;
58 | boldBlot.format('color', 'red');
59 | expect(boldBlot.domNode.style.color).toEqual('red');
60 | const original = boldBlot.domNode.outerHTML;
61 | expect(function () {
62 | boldBlot.format('color', 'red');
63 | }).not.toThrow();
64 | expect(boldBlot.domNode.outerHTML).toEqual(original);
65 | });
66 |
67 | it('replace existing class', function () {
68 | const blockBlot = ctx.scroll.create('block') as BlockBlot;
69 | blockBlot.format('indent', 2);
70 | expect(blockBlot.domNode.classList.contains('indent-2')).toBe(true);
71 | blockBlot.format('indent', 3);
72 | expect(blockBlot.domNode.classList.contains('indent-2')).toBe(false);
73 | expect(blockBlot.domNode.classList.contains('indent-3')).toBe(true);
74 | });
75 |
76 | it('add whitelist style', function () {
77 | const blockBlot = ctx.scroll.create('block') as BlockBlot;
78 | blockBlot.format('align', 'right');
79 | expect(blockBlot.domNode.style.textAlign).toBe('right');
80 | });
81 |
82 | it('add non-whitelisted style', function () {
83 | const blockBlot = ctx.scroll.create('block') as BlockBlot;
84 | blockBlot.format('align', 'justify');
85 | expect(blockBlot.domNode.style.textAlign).toBeFalsy();
86 | });
87 |
88 | it('unwrap', function () {
89 | const container = ctx.scroll.create('block') as BlockBlot;
90 | const node = document.createElement('strong');
91 | node.style.color = 'red';
92 | node.innerHTML = '0123';
93 | const blot = ctx.scroll.create(node);
94 | container.appendChild(blot);
95 | container.formatAt(0, 4, 'bold', false);
96 | expect(container.domNode.innerHTML).toEqual(
97 | '0123',
98 | );
99 | });
100 |
101 | it('remove', function () {
102 | const container = ctx.scroll.create('block') as BlockBlot;
103 | const node = document.createElement('strong');
104 | node.innerHTML = 'Bold';
105 | node.style.color = 'red';
106 | node.style.fontSize = '24px';
107 | container.domNode.classList.add('indent-5');
108 | container.domNode.id = 'test-remove';
109 | const boldBlot = ctx.scroll.create(node);
110 | container.appendChild(boldBlot);
111 | container.formatAt(1, 2, 'color', false);
112 | expect(container.children.length).toEqual(3);
113 | const targetNode = boldBlot.next?.domNode as HTMLElement;
114 | expect(targetNode.style.color).toEqual('');
115 | container.formatAt(1, 2, 'size', false);
116 | expect(targetNode.style.fontSize).toEqual('');
117 | expect(targetNode.getAttribute('style')).toEqual(null);
118 | container.formatAt(1, 2, 'indent', false);
119 | expect(targetNode.classList.contains('indent-5')).toBe(false);
120 | container.formatAt(1, 2, 'id', false);
121 | expect(container.domNode.id).toBeFalsy();
122 | });
123 |
124 | it('remove nonexistent', function () {
125 | const container = ctx.scroll.create('block') as BlockBlot;
126 | const node = document.createElement('strong');
127 | node.innerHTML = 'Bold';
128 | const boldBlot = ctx.scroll.create(node) as BoldBlot;
129 | container.appendChild(boldBlot);
130 | boldBlot.format('color', false);
131 | expect(container.domNode.innerHTML).toEqual('Bold');
132 | });
133 |
134 | it('keep class attribute after removal', function () {
135 | const boldBlot = ctx.scroll.create('bold') as BoldBlot;
136 | boldBlot.domNode.classList.add('blot');
137 | boldBlot.format('indent', 2);
138 | boldBlot.format('indent', false);
139 | expect(boldBlot.domNode.classList.contains('blot')).toBe(true);
140 | });
141 |
142 | it('move attribute', function () {
143 | const container = ctx.scroll.create('block') as BlockBlot;
144 | const node = document.createElement('strong');
145 | node.innerHTML = 'Bold';
146 | node.style.color = 'red';
147 | const boldBlot = ctx.scroll.create(node);
148 | container.appendChild(boldBlot);
149 | container.formatAt(1, 2, 'bold', false);
150 | expect(container.children.length).toEqual(3);
151 | expect(boldBlot.next?.statics.blotName).toEqual('inline');
152 | expect((boldBlot.next as Formattable)?.formats().color).toEqual('red');
153 | });
154 |
155 | it('wrap with inline', function () {
156 | const container = ctx.scroll.create('block') as BlockBlot;
157 | const node = document.createElement('strong');
158 | node.style.color = 'red';
159 | const boldBlot = ctx.scroll.create(node);
160 | container.appendChild(boldBlot);
161 | boldBlot.wrap('italic');
162 | expect(node.style.color).toBeFalsy();
163 | expect(node.parentElement?.style.color).toBe('red');
164 | });
165 |
166 | it('wrap with block', function () {
167 | const container = ctx.scroll.create('block') as BlockBlot;
168 | const node = document.createElement('strong');
169 | node.style.color = 'red';
170 | const boldBlot = ctx.scroll.create(node) as BoldBlot;
171 | container.appendChild(boldBlot);
172 | boldBlot.wrap('block');
173 | expect(node.style.color).toBe('red');
174 | expect(node.parentElement?.style.color).toBeFalsy();
175 | });
176 |
177 | it('add to block', function () {
178 | const container = ctx.scroll.create('block') as BlockBlot;
179 | const block = ctx.scroll.create('header', 'h1') as HeaderBlot;
180 | container.appendChild(block);
181 | block.format('align', 'right');
182 | expect(container.domNode.innerHTML).toBe(
183 | '',
184 | );
185 | expect((container.children.head as Formattable)?.formats()).toEqual({
186 | header: 'h1',
187 | align: 'right',
188 | });
189 | });
190 |
191 | it('missing class value', function () {
192 | const block = ctx.scroll.create('block');
193 | const indentAttributor = ctx.scroll.query('indent') as Attributor;
194 | expect(indentAttributor.value(block.domNode as HTMLElement)).toBeFalsy();
195 | });
196 |
197 | it('removes quotes from attribute value when checking if canAdd', function () {
198 | const bold = ctx.scroll.create('bold');
199 | const familyAttributor = ctx.scroll.query('family') as Attributor;
200 | const domNode = bold.domNode as HTMLElement;
201 | expect(familyAttributor.canAdd(domNode, 'Arial')).toBeTruthy();
202 | expect(familyAttributor.canAdd(domNode, '"Times New Roman"')).toBeTruthy();
203 | expect(familyAttributor.canAdd(domNode, 'monotype')).toBeFalsy();
204 | expect(familyAttributor.canAdd(domNode, '"Lucida Grande"')).toBeFalsy();
205 | });
206 | });
207 |
--------------------------------------------------------------------------------
/tests/unit/block.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import type { BlockBlot } from '../../src/parchment.js';
3 | import type { HeaderBlot } from '../__helpers__/registry/block.js';
4 | import { setupContextBeforeEach } from '../setup.js';
5 |
6 | describe('Block', function () {
7 | const ctx = setupContextBeforeEach();
8 |
9 | describe('format', function () {
10 | it('add', function () {
11 | const block = ctx.scroll.create('block') as BlockBlot;
12 | ctx.scroll.appendChild(block);
13 | block.format('header', 'h1');
14 | expect(ctx.scroll.domNode.innerHTML).toBe('');
15 | const childrenHead = ctx.scroll.children.head as HeaderBlot;
16 | expect(childrenHead.statics.blotName).toBe('header');
17 | expect(childrenHead.formats()).toEqual({ header: 'h1' });
18 | });
19 |
20 | it('remove', function () {
21 | const block = ctx.scroll.create('header', 'h1') as HeaderBlot;
22 | ctx.scroll.appendChild(block);
23 | block.format('header', false);
24 | expect(ctx.scroll.domNode.innerHTML).toBe('');
25 | const childrenHead = ctx.scroll.children.head as BlockBlot;
26 | expect(childrenHead.statics.blotName).toBe('block');
27 | expect(childrenHead.formats()).toEqual({});
28 | });
29 |
30 | it('change', function () {
31 | const block = ctx.scroll.create('block') as BlockBlot;
32 | const text = ctx.scroll.create('text', 'Test');
33 | block.appendChild(text);
34 | ctx.scroll.appendChild(block);
35 | block.format('header', 'h2');
36 | expect(ctx.scroll.domNode.innerHTML).toBe('Test
');
37 | const childrenHead = ctx.scroll.children.head as HeaderBlot;
38 | expect(childrenHead.statics.blotName).toBe('header');
39 | expect(childrenHead.formats()).toEqual({ header: 'h2' });
40 | expect(childrenHead.children.length).toBe(1);
41 | expect(childrenHead.children.head).toBe(text);
42 | });
43 |
44 | it('split', function () {
45 | const block = ctx.scroll.create('block') as BlockBlot;
46 | const text = ctx.scroll.create('text', 'Test');
47 | block.appendChild(text);
48 | ctx.scroll.appendChild(block);
49 | const src = 'http://www.w3schools.com/html/mov_bbb.mp4';
50 | block.insertAt(2, 'video', src);
51 | expect(ctx.scroll.domNode.innerHTML).toBe(
52 | `Te
st
`,
53 | );
54 | expect(ctx.scroll.children.length).toBe(3);
55 | expect(ctx.scroll.children.head?.next?.statics.blotName).toBe('video');
56 | });
57 |
58 | it('ignore inline', function () {
59 | const block = ctx.scroll.create('header', 1) as HeaderBlot;
60 | ctx.scroll.appendChild(block);
61 | block.format('bold', true);
62 | expect(ctx.scroll.domNode.innerHTML).toBe('');
63 | const childrenHead = ctx.scroll.children.head as HeaderBlot;
64 | expect(childrenHead.statics.blotName).toBe('header');
65 | expect(childrenHead.formats()).toEqual({ header: 'h1' });
66 | });
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/tests/unit/blot.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import type { BlockBlot, Parent } from '../../src/parchment.js';
3 | import Registry from '../../src/registry.js';
4 | import type { ItalicBlot } from '../__helpers__/registry/inline.js';
5 | import { setupContextBeforeEach } from '../setup.js';
6 |
7 | describe('Blot', function () {
8 | const ctx = setupContextBeforeEach();
9 |
10 | it('offset()', function () {
11 | const blockNode = document.createElement('p');
12 | blockNode.innerHTML = '012345';
13 | const blockBlot = ctx.scroll.create(blockNode) as BlockBlot;
14 | const boldBlot = (blockBlot.children.tail as Parent)?.children.tail;
15 | expect(boldBlot?.offset()).toEqual(2);
16 | expect(boldBlot?.offset(blockBlot)).toEqual(4);
17 | });
18 |
19 | it('detach()', function () {
20 | const blot = ctx.scroll.create('block');
21 | expect(Registry.blots.get(blot.domNode)).toEqual(blot);
22 | blot.detach();
23 | expect(Registry.blots.get(blot.domNode)).toEqual(undefined);
24 | });
25 |
26 | it('remove()', function () {
27 | const blot = ctx.scroll.create('block') as BlockBlot;
28 | const text = ctx.scroll.create('text', 'Test');
29 | blot.appendChild(text);
30 | expect(blot.children.head).toBe(text);
31 | expect(blot.domNode.innerHTML).toBe('Test');
32 | text.remove();
33 | expect(blot.children.length).toBe(0);
34 | expect(blot.domNode.innerHTML).toBe('');
35 | });
36 |
37 | it('wrap()', function () {
38 | const parent = ctx.scroll.create('block') as BlockBlot;
39 | const head = ctx.scroll.create('bold');
40 | const text = ctx.scroll.create('text', 'Test');
41 | const tail = ctx.scroll.create('bold');
42 | parent.appendChild(head);
43 | parent.appendChild(text);
44 | parent.appendChild(tail);
45 | expect(parent.domNode.innerHTML).toEqual(
46 | 'Test',
47 | );
48 | const wrapper = text.wrap('italic', true);
49 | expect(parent.domNode.innerHTML).toEqual(
50 | 'Test',
51 | );
52 | expect(parent.children.head).toEqual(head);
53 | expect(parent.children.head?.next).toEqual(wrapper);
54 | expect(parent.children.tail).toEqual(tail);
55 | });
56 |
57 | it('wrap() with blot', function () {
58 | const parent = ctx.scroll.create('block') as BlockBlot;
59 | const text = ctx.scroll.create('text', 'Test');
60 | const italic = ctx.scroll.create('italic') as ItalicBlot;
61 | parent.appendChild(text);
62 | text.wrap(italic);
63 | expect(parent.domNode.innerHTML).toEqual('Test');
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/tests/unit/container.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import { setupContextBeforeEach } from '../setup.js';
3 |
4 | describe('Container', function () {
5 | const ctx = setupContextBeforeEach();
6 |
7 | beforeEach(function () {
8 | ctx.container.innerHTML = '- 1
';
9 | });
10 |
11 | describe('enforceAllowedChildren()', function () {
12 | it('keep allowed', function () {
13 | const li = document.createElement('li');
14 | li.innerHTML = '2';
15 | ctx.scroll.domNode.firstChild?.appendChild(li);
16 | ctx.scroll.update();
17 | expect(ctx.scroll.domNode.innerHTML).toEqual(
18 | '- 1
- 2
',
19 | );
20 | });
21 |
22 | it('remove unallowed child', function () {
23 | const strong = document.createElement('strong');
24 | strong.innerHTML = '2';
25 | ctx.scroll.domNode.firstChild?.appendChild(strong);
26 | ctx.scroll.update();
27 | expect(ctx.scroll.domNode.innerHTML).toEqual('- 1
');
28 | });
29 |
30 | it('isolate block', function () {
31 | const header = document.createElement('h1');
32 | header.innerHTML = '2';
33 | ctx.scroll.domNode.firstChild?.appendChild(header);
34 | ctx.scroll.update();
35 | expect(ctx.scroll.domNode.innerHTML).toEqual(
36 | '- 1
2
',
37 | );
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/tests/unit/embed.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import type { BlockBlot, InlineBlot } from '../../src/parchment.js';
3 | import type { ImageBlot } from '../__helpers__/registry/embed.js';
4 | import { setupContextBeforeEach } from '../setup.js';
5 |
6 | describe('EmbedBlot', function () {
7 | const ctx = setupContextBeforeEach();
8 |
9 | it('value()', function () {
10 | const imageBlot = ctx.scroll.create('image', 'favicon.ico') as ImageBlot;
11 | expect(imageBlot.value()).toEqual({
12 | image: 'favicon.ico',
13 | });
14 | });
15 |
16 | it('deleteAt()', function () {
17 | const container = ctx.scroll.create('block') as BlockBlot;
18 | const imageBlot = ctx.scroll.create('image') as ImageBlot;
19 | container.appendChild(imageBlot);
20 | container.insertAt(1, '!');
21 | container.deleteAt(0, 1);
22 | expect(container.length()).toBe(1);
23 | expect(container.children.length).toBe(1);
24 | expect(imageBlot.domNode.parentNode).toBeFalsy();
25 | });
26 |
27 | it('format()', function () {
28 | const container = ctx.scroll.create('block') as BlockBlot;
29 | const imageBlot = ctx.scroll.create('image') as ImageBlot;
30 | container.appendChild(imageBlot);
31 | imageBlot.format('alt', 'Quill Icon');
32 | expect(imageBlot.formats()).toEqual({ alt: 'Quill Icon' });
33 | });
34 |
35 | it('formatAt()', function () {
36 | const container = ctx.scroll.create('block') as BlockBlot;
37 | const imageBlot = ctx.scroll.create('image');
38 | container.appendChild(imageBlot);
39 | container.formatAt(0, 1, 'color', 'red');
40 | expect(container.children.head?.statics.blotName).toBe('inline');
41 | });
42 |
43 | it('insertAt()', function () {
44 | const container = ctx.scroll.create('inline') as InlineBlot;
45 | const imageBlot = ctx.scroll.create('image');
46 | container.appendChild(imageBlot);
47 | imageBlot.insertAt(0, 'image', true);
48 | imageBlot.insertAt(0, '|');
49 | imageBlot.insertAt(1, '!');
50 | expect(container.domNode.innerHTML).toEqual('
|
!');
51 | });
52 |
53 | it('split()', function () {
54 | const blockNode = document.createElement('p');
55 | blockNode.innerHTML = 'Te
st';
56 | const blockBlot = ctx.scroll.create(blockNode) as BlockBlot;
57 | const imageBlot = blockBlot.children.head?.next;
58 | expect(imageBlot?.split(0)).toBe(imageBlot);
59 | expect(imageBlot?.split(1)).toBe(blockBlot.children.tail);
60 | });
61 |
62 | it('index()', function () {
63 | const imageBlot = ctx.scroll.create('image') as ImageBlot;
64 | expect(imageBlot.index(imageBlot.domNode, 0)).toEqual(0);
65 | expect(imageBlot.index(imageBlot.domNode, 1)).toEqual(1);
66 | expect(imageBlot.index(document.body, 1)).toEqual(-1);
67 | });
68 |
69 | it('position()', function () {
70 | const container = ctx.scroll.create('block') as BlockBlot;
71 | const imageBlot = ctx.scroll.create('image') as ImageBlot;
72 | container.appendChild(imageBlot);
73 | const [node, offset] = imageBlot.position(1, true);
74 | expect(node).toEqual(container.domNode);
75 | expect(offset).toEqual(1);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/tests/unit/inline.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import type { BlockBlot, Leaf } from '../../src/parchment.js';
3 | import type {
4 | BoldBlot,
5 | ItalicBlot,
6 | ScriptBlot,
7 | } from '../__helpers__/registry/inline.js';
8 | import { setupContextBeforeEach } from '../setup.js';
9 |
10 | describe('InlineBlot', function () {
11 | const ctx = setupContextBeforeEach();
12 |
13 | it('format addition', function () {
14 | const italicBlot = ctx.scroll.create('italic') as ItalicBlot;
15 | italicBlot.appendChild(ctx.scroll.create('text', 'Test'));
16 | italicBlot.formatAt(1, 2, 'bold', true);
17 | expect(italicBlot.domNode.outerHTML).toEqual(
18 | 'Test',
19 | );
20 | });
21 |
22 | it('format invalid', function () {
23 | const boldBlot = ctx.scroll.create('bold') as BoldBlot;
24 | boldBlot.appendChild(ctx.scroll.create('text', 'Test'));
25 | const original = boldBlot.domNode.outerHTML;
26 | expect(function () {
27 | boldBlot.format('nonexistent', true);
28 | }).not.toThrowError(/\[Parchment\]/);
29 | expect(boldBlot.domNode.outerHTML).toEqual(original);
30 | });
31 |
32 | it('format existing', function () {
33 | const italicBlot = ctx.scroll.create('italic') as ItalicBlot;
34 | const boldBlot = ctx.scroll.create('bold') as BoldBlot;
35 | boldBlot.appendChild(ctx.scroll.create('text', 'Test'));
36 | italicBlot.appendChild(boldBlot);
37 | const original = italicBlot.domNode.outerHTML;
38 | expect(function () {
39 | boldBlot.formatAt(0, 4, 'bold', true);
40 | italicBlot.formatAt(0, 4, 'italic', true);
41 | }).not.toThrowError(/\[Parchment\]/);
42 | expect(italicBlot.domNode.outerHTML).toEqual(original);
43 | });
44 |
45 | it('format removal nonexistent', function () {
46 | const container = ctx.scroll.create('block') as BlockBlot;
47 | const italicBlot = ctx.scroll.create('italic') as ItalicBlot;
48 | italicBlot.appendChild(ctx.scroll.create('text', 'Test'));
49 | container.appendChild(italicBlot);
50 | const original = italicBlot.domNode.outerHTML;
51 | expect(function () {
52 | italicBlot.format('bold', false);
53 | }).not.toThrowError(/\[Parchment\]/);
54 | expect(italicBlot.domNode.outerHTML).toEqual(original);
55 | });
56 |
57 | it('delete + unwrap', function () {
58 | const node = document.createElement('p');
59 | node.innerHTML = 'Test!';
60 | const container = ctx.scroll.create(node) as BlockBlot;
61 | container.deleteAt(0, 4);
62 | expect((container.children.head as Leaf).value()).toEqual('!');
63 | });
64 |
65 | it('formats()', function () {
66 | const italic = document.createElement('em');
67 | italic.style.color = 'red';
68 | italic.innerHTML = 'Test!';
69 | const blot = ctx.scroll.create(italic) as ItalicBlot;
70 | expect(blot.formats()).toEqual({ italic: true, color: 'red' });
71 | });
72 |
73 | it('change', function () {
74 | const container = ctx.scroll.create('block') as BlockBlot;
75 | const script = ctx.scroll.create('script', 'sup') as ScriptBlot;
76 | container.appendChild(script);
77 | script.format('script', 'sub');
78 | expect(container.domNode.innerHTML).toEqual('');
79 | expect((container.children.head as ScriptBlot).formats()).toEqual({
80 | script: 'sub',
81 | });
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/tests/unit/lifecycle.test.ts:
--------------------------------------------------------------------------------
1 | import { vi, describe, it, expect, beforeEach } from 'vitest';
2 | import LeafBlot from '../../src/blot/abstract/leaf.js';
3 | import ShadowBlot from '../../src/blot/abstract/shadow.js';
4 | import type {
5 | BlockBlot,
6 | Blot,
7 | InlineBlot,
8 | TextBlot,
9 | } from '../../src/parchment.js';
10 | import { HeaderBlot } from '../__helpers__/registry/block.js';
11 | import { ImageBlot } from '../__helpers__/registry/embed.js';
12 | import type { ItalicBlot } from '../__helpers__/registry/inline.js';
13 | import { BoldBlot } from '../__helpers__/registry/inline.js';
14 | import { setupContextBeforeEach } from '../setup.js';
15 |
16 | describe('Lifecycle', function () {
17 | const ctx = setupContextBeforeEach();
18 |
19 | describe('create()', function () {
20 | it('specific tagName', function () {
21 | const node = BoldBlot.create();
22 | expect(node).toBeTruthy();
23 | expect(node.tagName).toEqual(BoldBlot.tagName.toUpperCase());
24 | });
25 |
26 | it('array tagName index', function () {
27 | const node = HeaderBlot.create(2);
28 | expect(node).toBeTruthy();
29 | const blot = ctx.scroll.create(node) as HeaderBlot;
30 | expect(blot.formats()).toEqual({ header: 'h2' });
31 | });
32 |
33 | it('array tagName value', function () {
34 | const node = HeaderBlot.create('h2');
35 | expect(node).toBeTruthy();
36 | const blot = ctx.scroll.create(node) as HeaderBlot;
37 | expect(blot.formats()).toEqual({ header: 'h2' });
38 | });
39 |
40 | it('array tagName default', function () {
41 | const node = HeaderBlot.create();
42 | expect(node).toBeTruthy();
43 | const blot = ctx.scroll.create(node) as HeaderBlot;
44 | expect(blot.formats()).toEqual({ header: 'h1' });
45 | });
46 |
47 | it('null tagName', function () {
48 | class NullBlot extends ShadowBlot {}
49 | expect(NullBlot.create.bind(NullBlot)).toThrowError(/\[Parchment\]/);
50 | });
51 |
52 | it('className', function () {
53 | class ClassBlot extends ShadowBlot {
54 | static className = 'test';
55 | static tagName = 'span';
56 | static create() {
57 | return super.create() as HTMLElement;
58 | }
59 | }
60 | const node = ClassBlot.create();
61 | expect(node).toBeTruthy();
62 | expect(node.classList.contains('test')).toBe(true);
63 | expect(node.tagName).toBe('SPAN');
64 | });
65 | });
66 |
67 | describe('optimize()', function () {
68 | it('unwrap empty inline', function () {
69 | const node = document.createElement('p');
70 | node.innerHTML =
71 | 'Test';
72 | const block = ctx.scroll.create(node);
73 | ctx.scroll.appendChild(block);
74 | const span = ctx.scroll.find(node.querySelector('span')) as InlineBlot;
75 | span.format('color', false);
76 | ctx.scroll.optimize();
77 | expect(ctx.container.innerHTML).toEqual(
78 | 'Test
',
79 | );
80 | });
81 |
82 | it('unwrap recursive', function () {
83 | const node = document.createElement('p');
84 | node.innerHTML = 'Test';
85 | const block = ctx.scroll.create(node);
86 | ctx.scroll.appendChild(block);
87 | const text = ctx.scroll.find(
88 | node.querySelector('strong')?.firstChild as HTMLElement,
89 | );
90 | text?.deleteAt(0, 4);
91 | ctx.scroll.optimize();
92 | expect(ctx.container.innerHTML).toEqual('');
93 | });
94 |
95 | it('format merge', function () {
96 | const node = document.createElement('p');
97 | node.innerHTML = 'Test';
98 | const block = ctx.scroll.create(node);
99 | ctx.scroll.appendChild(block);
100 | const text = ctx.scroll.find(node.childNodes[1]);
101 | text?.formatAt(0, 2, 'bold', true);
102 | ctx.scroll.optimize();
103 | expect(ctx.container.innerHTML).toEqual('Test
');
104 | expect(ctx.container.querySelector('strong')?.childNodes.length).toBe(1);
105 | });
106 |
107 | it('format recursive merge', function () {
108 | const node = document.createElement('p');
109 | node.innerHTML =
110 | 'Test';
111 | const block = ctx.scroll.create(node);
112 | ctx.scroll.appendChild(block);
113 | const target = ctx.scroll.find(node.childNodes[1]);
114 | target?.wrap('italic', true);
115 | ctx.scroll.optimize();
116 | expect(ctx.container.innerHTML).toEqual(
117 | 'Test
',
118 | );
119 | expect(ctx.container.querySelector('strong')?.childNodes.length).toBe(1);
120 | });
121 |
122 | it('remove format merge', function () {
123 | const node = document.createElement('p');
124 | node.innerHTML =
125 | 'Test';
126 | const block = ctx.scroll.create(node);
127 | ctx.scroll.appendChild(block);
128 | block.formatAt(1, 2, 'italic', false);
129 | ctx.scroll.optimize();
130 | expect(ctx.container.innerHTML).toEqual('Test
');
131 | expect(ctx.container.querySelector('strong')?.childNodes.length).toBe(1);
132 | });
133 |
134 | it('remove attribute merge', function () {
135 | const node = document.createElement('p');
136 | node.innerHTML = 'Test';
137 | const block = ctx.scroll.create(node);
138 | ctx.scroll.appendChild(block);
139 | block.formatAt(1, 2, 'color', false);
140 | ctx.scroll.optimize();
141 | expect(ctx.container.innerHTML).toEqual('Test
');
142 | expect(ctx.container.querySelector('em')?.childNodes.length).toBe(1);
143 | });
144 |
145 | it('format no merge attribute mismatch', function () {
146 | const node = document.createElement('p');
147 | node.innerHTML =
148 | 'Test';
149 | const block = ctx.scroll.create(node);
150 | ctx.scroll.appendChild(block);
151 | block.formatAt(2, 2, 'italic', false);
152 | ctx.scroll.optimize();
153 | expect(ctx.container.innerHTML).toEqual(
154 | 'Test
',
155 | );
156 | });
157 |
158 | it('delete + merge', function () {
159 | const node = document.createElement('p');
160 | node.innerHTML = 'Test';
161 | const block = ctx.scroll.create(node);
162 | ctx.scroll.appendChild(block);
163 | block.deleteAt(1, 2);
164 | ctx.scroll.optimize();
165 | expect(ctx.container.innerHTML).toEqual('Tt
');
166 | expect(ctx.container.querySelector('em')?.childNodes.length).toBe(1);
167 | });
168 |
169 | it('unwrap + recursive merge', function () {
170 | const node = document.createElement('p');
171 | node.innerHTML =
172 | 'Test';
173 | const block = ctx.scroll.create(node);
174 | ctx.scroll.appendChild(block);
175 | block.formatAt(1, 2, 'italic', false);
176 | block.formatAt(1, 2, 'color', false);
177 | ctx.scroll.optimize();
178 | expect(ctx.container.innerHTML).toEqual('Test
');
179 | expect(ctx.container.querySelector('strong')?.childNodes.length).toBe(1);
180 | });
181 |
182 | it('remove text + recursive merge', function () {
183 | const node = document.createElement('p');
184 | node.innerHTML = 'Te|st';
185 | const block = ctx.scroll.create(node);
186 | ctx.scroll.appendChild(block);
187 | (node.childNodes[1] as Text).data = '';
188 | ctx.scroll.optimize();
189 | expect(ctx.container.innerHTML).toEqual('Test
');
190 | expect(ctx.container.firstChild?.firstChild?.childNodes.length).toBe(1);
191 | });
192 |
193 | it('insert default child', function () {
194 | HeaderBlot.defaultChild = ImageBlot;
195 | const blot = ctx.scroll.create('header') as HeaderBlot;
196 | expect(blot.domNode.innerHTML).toEqual('');
197 | blot.optimize();
198 | HeaderBlot.defaultChild = undefined;
199 | expect(blot.domNode.outerHTML).toEqual('![]()
');
200 | });
201 | });
202 |
203 | describe('update()', function () {
204 | // [p, em, strong, text, image, text, p, em, text]
205 | const ContentFixture =
206 | 'Test
ing
!
';
207 | type Blots /* corresponds to ContentFixture */ = [
208 | BlockBlot,
209 | ItalicBlot,
210 | BoldBlot,
211 | TextBlot,
212 | ImageBlot,
213 | TextBlot,
214 | BlockBlot,
215 | ItalicBlot,
216 | TextBlot,
217 | ];
218 | type UpdateTestContext = {
219 | checkUpdateCalls: (called: Blot | Blot[]) => void;
220 | checkValues: (expected: any[]) => void;
221 | descendants: Blots;
222 | };
223 | const updateCtx = {} as UpdateTestContext;
224 | beforeEach(function () {
225 | ctx.container.innerHTML = ContentFixture;
226 | ctx.scroll.update();
227 | updateCtx.descendants = ctx.scroll.descendants(ShadowBlot) as Blots;
228 | updateCtx.descendants.forEach(function (blot: ShadowBlot) {
229 | vi.spyOn(blot, 'update');
230 | });
231 | updateCtx.checkUpdateCalls = (called) => {
232 | updateCtx.descendants.forEach(function (blot) {
233 | if (
234 | called === blot ||
235 | (Array.isArray(called) && called.indexOf(blot) > -1)
236 | ) {
237 | expect(blot.update).toHaveBeenCalled();
238 | } else {
239 | expect(blot.update).not.toHaveBeenCalled();
240 | }
241 | });
242 | };
243 | updateCtx.checkValues = (expected) => {
244 | const values = ctx.scroll.descendants(LeafBlot).map(function (leaf) {
245 | return leaf.value();
246 | });
247 | expect(values).toEqual(expected);
248 | };
249 | });
250 |
251 | describe('api', function () {
252 | it('insert text', function () {
253 | ctx.scroll.insertAt(2, '|');
254 | ctx.scroll.optimize();
255 | updateCtx.checkValues(['Te|st', { image: true }, 'ing', '!']);
256 | expect(ctx.scroll.observer.takeRecords()).toEqual([]);
257 | });
258 |
259 | it('insert embed', function () {
260 | ctx.scroll.insertAt(2, 'image', true);
261 | ctx.scroll.optimize();
262 | updateCtx.checkValues([
263 | 'Te',
264 | { image: true },
265 | 'st',
266 | { image: true },
267 | 'ing',
268 | '!',
269 | ]);
270 | expect(ctx.scroll.observer.takeRecords()).toEqual([]);
271 | });
272 |
273 | it('delete', function () {
274 | ctx.scroll.deleteAt(2, 5);
275 | ctx.scroll.optimize();
276 | updateCtx.checkValues(['Te', 'g', '!']);
277 | expect(ctx.scroll.observer.takeRecords()).toEqual([]);
278 | });
279 |
280 | it('format', function () {
281 | ctx.scroll.formatAt(2, 5, 'size', '24px');
282 | ctx.scroll.optimize();
283 | updateCtx.checkValues(['Te', 'st', { image: true }, 'in', 'g', '!']);
284 | expect(ctx.scroll.observer.takeRecords()).toEqual([]);
285 | });
286 | });
287 |
288 | describe('dom', function () {
289 | it('change text', function () {
290 | const textBlot = updateCtx.descendants[3];
291 | textBlot.domNode.data = 'Te|st';
292 | ctx.scroll.update();
293 | updateCtx.checkUpdateCalls(textBlot);
294 | expect(textBlot.value()).toEqual('Te|st');
295 | });
296 |
297 | it('add/remove unknown element', function () {
298 | const unknownElement = document.createElement('unknownElement');
299 | const unknownElement2 = document.createElement('unknownElement2');
300 | ctx.scroll.domNode.appendChild(unknownElement);
301 | unknownElement.appendChild(unknownElement2);
302 | ctx.scroll.domNode.removeChild(unknownElement);
303 | ctx.scroll.update();
304 | updateCtx.checkValues(['Test', { image: true }, 'ing', '!']);
305 | });
306 |
307 | it('add attribute', function () {
308 | const attrBlot = updateCtx.descendants[1];
309 | attrBlot.domNode.setAttribute('id', 'blot');
310 | ctx.scroll.update();
311 | updateCtx.checkUpdateCalls(attrBlot);
312 | expect(attrBlot.formats()).toEqual({
313 | color: 'red',
314 | italic: true,
315 | id: 'blot',
316 | });
317 | });
318 |
319 | it('add embed attribute', function () {
320 | const imageBlot = updateCtx.descendants[4];
321 | imageBlot.domNode.setAttribute('alt', 'image');
322 | ctx.scroll.update();
323 | updateCtx.checkUpdateCalls(imageBlot);
324 | });
325 |
326 | it('change attributes', function () {
327 | const attrBlot = updateCtx.descendants[1];
328 | attrBlot.domNode.style.color = 'blue';
329 | ctx.scroll.update();
330 | updateCtx.checkUpdateCalls(attrBlot);
331 | expect(attrBlot.formats()).toEqual({ color: 'blue', italic: true });
332 | });
333 |
334 | it('remove attribute', function () {
335 | const attrBlot = updateCtx.descendants[1];
336 | attrBlot.domNode.removeAttribute('style');
337 | ctx.scroll.update();
338 | updateCtx.checkUpdateCalls(attrBlot);
339 | expect(attrBlot.formats()).toEqual({ italic: true });
340 | });
341 |
342 | it('add child node', function () {
343 | const italicBlot = updateCtx.descendants[1];
344 | italicBlot.domNode.appendChild(document.createTextNode('|'));
345 | ctx.scroll.update();
346 | updateCtx.checkUpdateCalls(italicBlot);
347 | updateCtx.checkValues(['Test', { image: true }, 'ing|', '!']);
348 | });
349 |
350 | it('add empty family', function () {
351 | const blockBlot = updateCtx.descendants[0];
352 | const boldNode = document.createElement('strong');
353 | const html = ctx.scroll.domNode.innerHTML;
354 | boldNode.appendChild(document.createTextNode(''));
355 | blockBlot.domNode.appendChild(boldNode);
356 | ctx.scroll.update();
357 | updateCtx.checkUpdateCalls(blockBlot);
358 | expect(ctx.scroll.domNode.innerHTML).toBe(html);
359 | expect(ctx.scroll.descendants(ShadowBlot).length).toEqual(
360 | updateCtx.descendants.length,
361 | );
362 | });
363 |
364 | it('move node up', function () {
365 | const imageBlot = updateCtx.descendants[4];
366 | imageBlot.domNode.parentNode?.insertBefore(
367 | imageBlot.domNode,
368 | imageBlot.domNode.previousSibling,
369 | );
370 | ctx.scroll.update();
371 | updateCtx.checkUpdateCalls(imageBlot.parent);
372 | updateCtx.checkValues([{ image: true }, 'Test', 'ing', '!']);
373 | });
374 |
375 | it('move node down', function () {
376 | const imageBlot = updateCtx.descendants[4];
377 | imageBlot.domNode.parentNode?.insertBefore(
378 | imageBlot.domNode.nextSibling!,
379 | imageBlot.domNode,
380 | );
381 | ctx.scroll.update();
382 | updateCtx.checkUpdateCalls(imageBlot.parent);
383 | updateCtx.checkValues(['Test', 'ing', { image: true }, '!']);
384 | });
385 |
386 | it('move node and change', function () {
387 | const firstBlockBlot = updateCtx.descendants[0];
388 | const lastItalicBlot = updateCtx.descendants[7];
389 | firstBlockBlot.domNode.appendChild(lastItalicBlot.domNode);
390 | lastItalicBlot.domNode.innerHTML = '?';
391 | ctx.scroll.update();
392 | updateCtx.checkUpdateCalls([
393 | firstBlockBlot,
394 | updateCtx.descendants[6],
395 | updateCtx.descendants[7],
396 | ]);
397 | updateCtx.checkValues(['Test', { image: true }, 'ing', '?']);
398 | });
399 |
400 | it('add and remove consecutive nodes', function () {
401 | const italicBlot = updateCtx.descendants[1];
402 | const imageNode = document.createElement('img');
403 | const textNode = document.createTextNode('|');
404 | const refNode = italicBlot.domNode.childNodes[1]; // Old img
405 | italicBlot.domNode.insertBefore(textNode, refNode);
406 | italicBlot.domNode.insertBefore(imageNode, textNode);
407 | italicBlot.domNode.removeChild(refNode);
408 | ctx.scroll.update();
409 | updateCtx.checkUpdateCalls(italicBlot);
410 | updateCtx.checkValues(['Test', { image: true }, '|ing', '!']);
411 | });
412 |
413 | it('wrap text', function () {
414 | const textNode = updateCtx.descendants[5].domNode;
415 | const spanNode = document.createElement('span');
416 | textNode.parentNode?.removeChild(textNode);
417 | ctx.scroll.domNode.lastChild?.appendChild(spanNode);
418 | spanNode.appendChild(textNode);
419 | ctx.scroll.update();
420 | updateCtx.checkValues(['Test', { image: true }, '!', 'ing']);
421 | });
422 |
423 | it('add then remove same node', function () {
424 | const italicBlot = updateCtx.descendants[1];
425 | const textNode = document.createTextNode('|');
426 | italicBlot.domNode.appendChild(textNode);
427 | italicBlot.domNode.removeChild(textNode);
428 | ctx.scroll.update();
429 | updateCtx.checkUpdateCalls(italicBlot);
430 | updateCtx.checkValues(['Test', { image: true }, 'ing', '!']);
431 | });
432 |
433 | it('remove child node', function () {
434 | const imageBlot = updateCtx.descendants[4];
435 | imageBlot.domNode.parentNode?.removeChild(imageBlot.domNode);
436 | ctx.scroll.update();
437 | updateCtx.checkUpdateCalls(updateCtx.descendants[1]);
438 | updateCtx.checkValues(['Test', 'ing', '!']);
439 | });
440 |
441 | it('change and remove node', function () {
442 | const italicBlot = updateCtx.descendants[1];
443 | // @ts-expect-error This simulates ignored dom mutation
444 | italicBlot.domNode.color = 'blue';
445 | italicBlot.domNode.parentNode?.removeChild(italicBlot.domNode);
446 | ctx.scroll.update();
447 | updateCtx.checkUpdateCalls(italicBlot.parent);
448 | updateCtx.checkValues(['!']);
449 | });
450 |
451 | it('change and remove parent', function () {
452 | const blockBlot = updateCtx.descendants[0];
453 | const italicBlot = updateCtx.descendants[1];
454 | // @ts-expect-error This simulates ignored dom mutation
455 | italicBlot.domNode.color = 'blue';
456 | ctx.scroll.domNode.removeChild(blockBlot.domNode);
457 | ctx.scroll.update();
458 | updateCtx.checkUpdateCalls([]);
459 | updateCtx.checkValues(['!']);
460 | });
461 |
462 | it('different changes to same blot', function () {
463 | const attrBlot = updateCtx.descendants[1];
464 | attrBlot.domNode.style.color = 'blue';
465 | attrBlot.domNode.insertBefore(
466 | document.createTextNode('|'),
467 | attrBlot.domNode.childNodes[1],
468 | );
469 | ctx.scroll.update();
470 | updateCtx.checkUpdateCalls(attrBlot);
471 | expect(attrBlot.formats()).toEqual({ color: 'blue', italic: true });
472 | updateCtx.checkValues(['Test', '|', { image: true }, 'ing', '!']);
473 | });
474 | });
475 | });
476 | });
477 |
--------------------------------------------------------------------------------
/tests/unit/linked-list.test.ts:
--------------------------------------------------------------------------------
1 | import { vi, describe, it, expect, beforeEach } from 'vitest';
2 | import LinkedList from '../../src/collection/linked-list.js';
3 | import type { LinkedNode } from '../../src/parchment.js';
4 |
5 | interface StrNode extends LinkedNode {
6 | str: string;
7 | }
8 |
9 | const setupContextBeforeEach = () => {
10 | const getContext = () => {
11 | const length = () => 3;
12 | return {
13 | list: new LinkedList(),
14 | a: { str: 'a', length } as StrNode,
15 | b: { str: 'b', length } as StrNode,
16 | c: { str: 'c', length } as StrNode,
17 | zero: { str: '!', length: () => 0 } as StrNode,
18 | };
19 | };
20 | const ctx = getContext();
21 | beforeEach(function () {
22 | Object.assign(ctx, getContext());
23 | });
24 | return ctx;
25 | };
26 |
27 | describe('LinkedList', function () {
28 | const ctx = setupContextBeforeEach();
29 |
30 | describe('manipulation', function () {
31 | it('append to empty list', function () {
32 | ctx.list.append(ctx.a);
33 | expect(ctx.list.length).toBe(1);
34 | expect(ctx.list.head).toBe(ctx.a);
35 | expect(ctx.list.tail).toBe(ctx.a);
36 | expect(ctx.a.prev).toBeNull();
37 | expect(ctx.a.next).toBeNull();
38 | });
39 |
40 | it('insert to become head', function () {
41 | ctx.list.append(ctx.b);
42 | ctx.list.insertBefore(ctx.a, ctx.b);
43 | expect(ctx.list.length).toBe(2);
44 | expect(ctx.list.head).toBe(ctx.a);
45 | expect(ctx.list.tail).toBe(ctx.b);
46 | expect(ctx.a.prev).toBeNull();
47 | expect(ctx.a.next).toBe(ctx.b);
48 | expect(ctx.b.prev).toBe(ctx.a);
49 | expect(ctx.b.next).toBeNull();
50 | });
51 |
52 | it('insert to become tail', function () {
53 | ctx.list.append(ctx.a);
54 | ctx.list.insertBefore(ctx.b, null);
55 | expect(ctx.list.length).toBe(2);
56 | expect(ctx.list.head).toBe(ctx.a);
57 | expect(ctx.list.tail).toBe(ctx.b);
58 | expect(ctx.a.prev).toBeNull();
59 | expect(ctx.a.next).toBe(ctx.b);
60 | expect(ctx.b.prev).toBe(ctx.a);
61 | expect(ctx.b.next).toBeNull();
62 | });
63 |
64 | it('insert in middle', function () {
65 | ctx.list.append(ctx.a, ctx.c);
66 | ctx.list.insertBefore(ctx.b, ctx.c);
67 | expect(ctx.list.length).toBe(3);
68 | expect(ctx.list.head).toBe(ctx.a);
69 | expect(ctx.a.next).toBe(ctx.b);
70 | expect(ctx.b.next).toBe(ctx.c);
71 | expect(ctx.list.tail).toBe(ctx.c);
72 | });
73 |
74 | it('remove head', function () {
75 | ctx.list.append(ctx.a, ctx.b);
76 | ctx.list.remove(ctx.a);
77 | expect(ctx.list.length).toBe(1);
78 | expect(ctx.list.head).toBe(ctx.b);
79 | expect(ctx.list.tail).toBe(ctx.b);
80 | expect(ctx.list.head?.prev).toBeNull();
81 | expect(ctx.list.tail?.next).toBeNull();
82 | });
83 |
84 | it('remove tail', function () {
85 | ctx.list.append(ctx.a, ctx.b);
86 | ctx.list.remove(ctx.b);
87 | expect(ctx.list.length).toBe(1);
88 | expect(ctx.list.head).toBe(ctx.a);
89 | expect(ctx.list.tail).toBe(ctx.a);
90 | expect(ctx.list.head?.prev).toBeNull();
91 | expect(ctx.list.tail?.next).toBeNull();
92 | });
93 |
94 | it('remove inner', function () {
95 | ctx.list.append(ctx.a, ctx.b, ctx.c);
96 | ctx.list.remove(ctx.b);
97 | expect(ctx.list.length).toBe(2);
98 | expect(ctx.list.head).toBe(ctx.a);
99 | expect(ctx.list.tail).toBe(ctx.c);
100 | expect(ctx.list.head?.prev).toBeNull();
101 | expect(ctx.list.tail?.next).toBeNull();
102 | expect(ctx.a.next).toBe(ctx.c);
103 | expect(ctx.c.prev).toBe(ctx.a);
104 | // Maintain references
105 | expect(ctx.b.prev).toBe(ctx.a);
106 | expect(ctx.b.next).toBe(ctx.c);
107 | });
108 |
109 | it('remove only node', function () {
110 | ctx.list.append(ctx.a);
111 | ctx.list.remove(ctx.a);
112 | expect(ctx.list.length).toBe(0);
113 | expect(ctx.list.head).toBeNull();
114 | expect(ctx.list.tail).toBeNull();
115 | });
116 |
117 | it('contains', function () {
118 | ctx.list.append(ctx.a, ctx.b);
119 | expect(ctx.list.contains(ctx.a)).toBe(true);
120 | expect(ctx.list.contains(ctx.b)).toBe(true);
121 | expect(ctx.list.contains(ctx.c)).toBe(false);
122 | });
123 |
124 | it('move', function () {
125 | ctx.list.append(ctx.a, ctx.b, ctx.c);
126 | ctx.list.remove(ctx.b);
127 | ctx.list.remove(ctx.a);
128 | ctx.list.remove(ctx.c);
129 | ctx.list.append(ctx.b);
130 | expect(ctx.b.prev).toBeNull();
131 | expect(ctx.b.next).toBeNull();
132 | });
133 | });
134 |
135 | describe('iteration', function () {
136 | const spy = vi.fn();
137 | beforeEach(function () {
138 | spy.mockReset();
139 | });
140 |
141 | it('iterate over empty list', function () {
142 | ctx.list.forEach(spy);
143 | expect(spy.mock.calls.length).toBe(0);
144 | });
145 |
146 | it('iterate non-head start', function () {
147 | ctx.list.append(ctx.a, ctx.b, ctx.c);
148 | const next = ctx.list.iterator(ctx.b);
149 | const b = next();
150 | const c = next();
151 | const d = next();
152 | expect(b).toBe(ctx.b);
153 | expect(c).toBe(ctx.c);
154 | expect(d).toBeNull();
155 | });
156 |
157 | it('find', function () {
158 | ctx.list.append(ctx.a, ctx.b, ctx.zero, ctx.c);
159 | expect(ctx.list.find(0)).toEqual([ctx.a, 0]);
160 | expect(ctx.list.find(2)).toEqual([ctx.a, 2]);
161 | expect(ctx.list.find(6)).toEqual([ctx.c, 0]);
162 | expect(ctx.list.find(3, true)).toEqual([ctx.a, 3]);
163 | expect(ctx.list.find(6, true)).toEqual([ctx.zero, 0]);
164 | expect(ctx.list.find(3)).toEqual([ctx.b, 0]);
165 | expect(ctx.list.find(4)).toEqual([ctx.b, 1]);
166 | expect(ctx.list.find(10)).toEqual([null, 0]);
167 | });
168 |
169 | it('offset', function () {
170 | ctx.list.append(ctx.a, ctx.b, ctx.c);
171 | expect(ctx.list.offset(ctx.a)).toBe(0);
172 | expect(ctx.list.offset(ctx.b)).toBe(3);
173 | expect(ctx.list.offset(ctx.c)).toBe(6);
174 | // @ts-expect-error Testing invalid usage
175 | expect(ctx.list.offset({})).toBe(-1);
176 | });
177 |
178 | it('forEach', function () {
179 | ctx.list.append(ctx.a, ctx.b, ctx.c);
180 | ctx.list.forEach(spy);
181 | expect(spy.mock.calls.length).toBe(3);
182 | const result = spy.mock.calls.reduce(
183 | (memo: string, call: StrNode[]) => memo + call[0].str,
184 | '',
185 | );
186 | expect(result).toBe('abc');
187 | });
188 |
189 | it('destructive modification', function () {
190 | ctx.list.append(ctx.a, ctx.b, ctx.c);
191 | const arr: string[] = [];
192 | ctx.list.forEach((node) => {
193 | arr.push(node.str);
194 | if (node === ctx.a) {
195 | ctx.list.remove(ctx.a);
196 | ctx.list.remove(ctx.b);
197 | ctx.list.append(ctx.a);
198 | }
199 | });
200 | expect(arr).toEqual(['a', 'b', 'c', 'a']);
201 | });
202 |
203 | it('map', function () {
204 | ctx.list.append(ctx.a, ctx.b, ctx.c);
205 | const arr = ctx.list.map(function (node) {
206 | return node.str;
207 | });
208 | expect(arr).toEqual(['a', 'b', 'c']);
209 | });
210 |
211 | it('reduce', function () {
212 | ctx.list.append(ctx.a, ctx.b, ctx.c);
213 | const memo = ctx.list.reduce(function (memo, node) {
214 | return memo + node.str;
215 | }, '');
216 | expect(memo).toBe('abc');
217 | });
218 |
219 | it('forEachAt', function () {
220 | ctx.list.append(ctx.a, ctx.b, ctx.c);
221 | ctx.list.forEachAt(3, 3, spy);
222 | expect(spy.mock.calls.length).toBe(1);
223 | expect(spy.mock.calls[0]).toEqual([ctx.b, 0, 3]);
224 | });
225 |
226 | it('forEachAt zero length nodes', function () {
227 | ctx.list.append(ctx.a, ctx.zero, ctx.c);
228 | ctx.list.forEachAt(2, 2, spy);
229 | expect(spy.mock.calls.length).toBe(3);
230 | const calls = spy.mock.calls;
231 | expect(calls[0]).toEqual([ctx.a, 2, 1]);
232 | expect(calls[1]).toEqual([ctx.zero, 0, 0]);
233 | expect(calls[2]).toEqual([ctx.c, 0, 1]);
234 | });
235 |
236 | it('forEachAt none', function () {
237 | ctx.list.append(ctx.a, ctx.b);
238 | ctx.list.forEachAt(1, 0, spy);
239 | expect(spy.mock.calls.length).toBe(0);
240 | });
241 |
242 | it('forEachAt partial nodes', function () {
243 | ctx.list.append(ctx.a, ctx.b, ctx.c);
244 | ctx.list.forEachAt(1, 7, spy);
245 | expect(spy.mock.calls.length).toBe(3);
246 | const calls = spy.mock.calls;
247 | expect(calls[0]).toEqual([ctx.a, 1, 2]);
248 | expect(calls[1]).toEqual([ctx.b, 0, 3]);
249 | expect(calls[2]).toEqual([ctx.c, 0, 2]);
250 | });
251 |
252 | it('forEachAt at part of single node', function () {
253 | ctx.list.append(ctx.a, ctx.b, ctx.c);
254 | ctx.list.forEachAt(4, 1, spy);
255 | expect(spy.mock.calls.length).toBe(1);
256 | expect(spy.mock.calls[0]).toEqual([ctx.b, 1, 1]);
257 | });
258 | });
259 | });
260 |
--------------------------------------------------------------------------------
/tests/unit/parent.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach } from 'vitest';
2 | import LeafBlot from '../../src/blot/abstract/leaf.js';
3 | import ParentBlot from '../../src/blot/abstract/parent.js';
4 | import ShadowBlot from '../../src/blot/abstract/shadow.js';
5 | import EmbedBlot from '../../src/blot/embed.js';
6 |
7 | import { VideoBlot } from '../__helpers__/registry/embed.js';
8 | import { ItalicBlot } from '../__helpers__/registry/inline.js';
9 |
10 | import Registry from '../../src/registry.js';
11 | import TextBlot from '../../src/blot/text.js';
12 | import { setupContextBeforeEach } from '../setup.js';
13 | import type { BlockBlot, Blot } from '../../src/parchment.js';
14 |
15 | describe('Parent', function () {
16 | const ctx = setupContextBeforeEach();
17 |
18 | let testBlot!: BlockBlot;
19 |
20 | beforeEach(function () {
21 | const node = document.createElement('p');
22 | node.innerHTML = '012
4';
23 | testBlot = ctx.registry.create(ctx.scroll, node) as BlockBlot;
24 | });
25 |
26 | describe('descendants()', function () {
27 | it('all', function () {
28 | expect(testBlot.descendants(ShadowBlot).length).toEqual(8);
29 | });
30 |
31 | it('container', function () {
32 | expect(testBlot.descendants(ParentBlot).length).toEqual(3);
33 | });
34 |
35 | it('leaf', function () {
36 | expect(testBlot.descendants(LeafBlot).length).toEqual(5);
37 | });
38 |
39 | it('embed', function () {
40 | expect(testBlot.descendants(EmbedBlot).length).toEqual(1);
41 | });
42 |
43 | it('range', function () {
44 | expect(testBlot.descendants(TextBlot, 1, 3).length).toEqual(2);
45 | });
46 |
47 | it('function match', function () {
48 | expect(
49 | testBlot.descendants(
50 | function (blot: Blot) {
51 | return blot instanceof TextBlot;
52 | },
53 | 1,
54 | 3,
55 | ).length,
56 | ).toEqual(2);
57 | });
58 | });
59 |
60 | describe('descendant', function () {
61 | it('index', function () {
62 | const [blot, offset] = testBlot.descendant(ItalicBlot, 3);
63 | expect(blot instanceof ItalicBlot).toBe(true);
64 | expect(offset).toEqual(2);
65 | });
66 |
67 | it('function match', function () {
68 | const [blot, offset] = testBlot.descendant(function (blot: Blot) {
69 | return blot instanceof ItalicBlot;
70 | }, 3);
71 | expect(blot instanceof ItalicBlot).toBe(true);
72 | expect(offset).toEqual(2);
73 | });
74 |
75 | it('no match', function () {
76 | const [blot, offset] = testBlot.descendant(VideoBlot, 1);
77 | expect(blot).toEqual(null);
78 | expect(offset).toEqual(-1);
79 | });
80 | });
81 |
82 | it('detach()', function () {
83 | expect(Registry.blots.get(testBlot.domNode)).toEqual(testBlot);
84 | expect(testBlot.descendants(ShadowBlot).length).toEqual(8);
85 | testBlot.detach();
86 | expect(Registry.blots.has(testBlot.domNode)).toBe(false);
87 | testBlot.descendants(ShadowBlot).forEach((blot) => {
88 | expect(Registry.blots.has(blot.domNode)).toBe(false);
89 | });
90 | });
91 |
92 | it('attach unknown blot', function () {
93 | const node = document.createElement('p');
94 | node.appendChild(document.createElement('input'));
95 | expect(() => {
96 | ctx.scroll.create(node);
97 | }).not.toThrowError(/\[Parchment\]/);
98 | });
99 |
100 | it('ignore added uiNode', function () {
101 | ctx.scroll.appendChild(testBlot);
102 | testBlot.attachUI(document.createElement('div'));
103 | ctx.scroll.update();
104 | expect(ctx.scroll.domNode.innerHTML).toEqual(
105 | '012
4
',
106 | );
107 | });
108 |
109 | it('allowedChildren', function () {
110 | ctx.scroll.domNode.innerHTML = 'A
BCD
';
111 | ctx.scroll.update();
112 | expect(ctx.scroll.domNode.innerHTML).toEqual('A
D
');
113 | });
114 | });
115 |
--------------------------------------------------------------------------------
/tests/unit/registry.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import Scope from '../../src/scope.js';
3 | import { HeaderBlot } from '../__helpers__/registry/block.js';
4 | import {
5 | AuthorBlot,
6 | BoldBlot,
7 | ItalicBlot,
8 | } from '../__helpers__/registry/inline.js';
9 |
10 | import ShadowBlot from '../../src/blot/abstract/shadow.js';
11 | import InlineBlot from '../../src/blot/inline.js';
12 | import BlockBlot from '../../src/blot/block.js';
13 | import type { Parent } from '../../src/parchment.js';
14 |
15 | import { setupContextBeforeEach } from '../setup.js';
16 |
17 | describe('ctx.registry', function () {
18 | const ctx = setupContextBeforeEach();
19 |
20 | describe('create()', function () {
21 | it('name', function () {
22 | const blot = ctx.registry.create(ctx.scroll, 'bold');
23 | expect(blot instanceof BoldBlot).toBe(true);
24 | expect(blot.statics.blotName).toBe('bold');
25 | });
26 |
27 | it('node', function () {
28 | const node = document.createElement('strong');
29 | const blot = ctx.registry.create(ctx.scroll, node);
30 | expect(blot instanceof BoldBlot).toBe(true);
31 | expect(blot.statics.blotName).toBe('bold');
32 | });
33 |
34 | it('block', function () {
35 | const blot = ctx.registry.create(ctx.scroll, Scope.BLOCK_BLOT);
36 | expect(blot instanceof BlockBlot).toBe(true);
37 | expect(blot.statics.blotName).toBe('block');
38 | });
39 |
40 | it('inline', function () {
41 | const blot = ctx.registry.create(ctx.scroll, Scope.INLINE_BLOT);
42 | expect(blot instanceof InlineBlot).toBe(true);
43 | expect(blot.statics.blotName).toBe('inline');
44 | });
45 |
46 | it('string index', function () {
47 | const blot = ctx.registry.create(ctx.scroll, 'header', '2');
48 | expect(blot instanceof HeaderBlot && blot.formats()).toEqual({
49 | header: 'h2',
50 | });
51 | });
52 |
53 | it('invalid', function () {
54 | expect(() => {
55 | // @ts-expect-error This tests invalid usage
56 | ctx.registry.create(ctx.scroll, BoldBlot);
57 | }).toThrowError(/\[Parchment\]/);
58 | });
59 | });
60 |
61 | describe('register()', function () {
62 | it('invalid', function () {
63 | expect(function () {
64 | // @ts-expect-error This tests invalid usage
65 | ctx.registry.register({});
66 | }).toThrowError(/\[Parchment\]/);
67 | });
68 |
69 | it('abstract', function () {
70 | expect(function () {
71 | ctx.registry.register(ShadowBlot);
72 | }).toThrowError(/\[Parchment\]/);
73 | });
74 | });
75 |
76 | describe('find()', function () {
77 | it('exact', function () {
78 | const blockNode = document.createElement('p');
79 | blockNode.innerHTML = '012345';
80 | const blockBlot = ctx.registry.create(ctx.scroll, blockNode) as BlockBlot;
81 | expect(ctx.registry.find(document.body)).toBeFalsy();
82 | expect(ctx.registry.find(blockNode)).toBe(blockBlot);
83 | expect(ctx.registry.find(blockNode.querySelector('span'))).toBe(
84 | blockBlot.children.head,
85 | );
86 | expect(ctx.registry.find(blockNode.querySelector('em'))).toBe(
87 | blockBlot.children.tail,
88 | );
89 | expect(ctx.registry.find(blockNode.querySelector('strong'))).toBe(
90 | (blockBlot.children.tail as Parent)?.children.tail,
91 | );
92 | const text01 = (blockBlot.children.head as Parent).children.head!;
93 | const text23 = (blockBlot.children.tail as Parent).children.head!;
94 | const text45 = (
95 | (blockBlot.children.tail as Parent).children.tail as Parent
96 | ).children.head!;
97 | expect(ctx.registry.find(text01.domNode)).toBe(text01);
98 | expect(ctx.registry.find(text23.domNode)).toBe(text23);
99 | expect(ctx.registry.find(text45.domNode)).toBe(text45);
100 | });
101 |
102 | it('bubble', function () {
103 | const blockBlot = ctx.registry.create(ctx.scroll, 'block');
104 | const textNode = document.createTextNode('Test');
105 | blockBlot.domNode.appendChild(textNode);
106 | expect(ctx.registry.find(textNode)).toBeFalsy();
107 | expect(ctx.registry.find(textNode, true)).toEqual(blockBlot);
108 | });
109 |
110 | it('detached parent', function () {
111 | const blockNode = document.createElement('p');
112 | blockNode.appendChild(document.createTextNode('Test'));
113 | expect(ctx.registry.find(blockNode.firstChild)).toBeFalsy();
114 | expect(ctx.registry.find(blockNode.firstChild, true)).toBeFalsy();
115 | });
116 |
117 | it('restricted parent', function () {
118 | const blockBlot = ctx.registry.create(ctx.scroll, 'block');
119 | const textNode = document.createTextNode('Test');
120 | blockBlot.domNode.appendChild(textNode);
121 | Object.defineProperty(textNode, 'parentNode', {
122 | get() {
123 | throw new Error('Permission denied to access property "parentNode"');
124 | },
125 | });
126 | expect(ctx.registry.find(textNode)).toEqual(null);
127 | expect(ctx.registry.find(textNode, true)).toEqual(null);
128 | });
129 | });
130 |
131 | describe('query()', function () {
132 | it('class', function () {
133 | const node = document.createElement('em');
134 | node.setAttribute('class', 'author-blot');
135 | expect(ctx.registry.query(node)).toBe(AuthorBlot);
136 | });
137 |
138 | it('type mismatch', function () {
139 | const match = ctx.registry.query('italic', Scope.ATTRIBUTE);
140 | expect(match).toBeFalsy();
141 | });
142 |
143 | it('level mismatch for blot', function () {
144 | const match = ctx.registry.query('italic', Scope.BLOCK);
145 | expect(match).toBeFalsy();
146 | });
147 |
148 | it('level mismatch for attribute', function () {
149 | const match = ctx.registry.query('color', Scope.BLOCK);
150 | expect(match).toBeFalsy();
151 | });
152 |
153 | it('either level', function () {
154 | const match = ctx.registry.query('italic', Scope.BLOCK | Scope.INLINE);
155 | expect(match).toBe(ItalicBlot);
156 | });
157 |
158 | it('level and type match', function () {
159 | const match = ctx.registry.query('italic', Scope.INLINE & Scope.BLOT);
160 | expect(match).toBe(ItalicBlot);
161 | });
162 |
163 | it('level match and type mismatch', function () {
164 | const match = ctx.registry.query(
165 | 'italic',
166 | Scope.INLINE & Scope.ATTRIBUTE,
167 | );
168 | expect(match).toBeFalsy();
169 | });
170 |
171 | it('type match and level mismatch', function () {
172 | const match = ctx.registry.query('italic', Scope.BLOCK & Scope.BLOT);
173 | expect(match).toBeFalsy();
174 | });
175 | });
176 | });
177 |
--------------------------------------------------------------------------------
/tests/unit/scroll.test.ts:
--------------------------------------------------------------------------------
1 | import { vi, describe, it, expect, beforeEach } from 'vitest';
2 | import { setupContextBeforeEach } from '../setup.js';
3 |
4 | describe('scroll', function () {
5 | const ctx = setupContextBeforeEach();
6 |
7 | beforeEach(function () {
8 | ctx.container.innerHTML =
9 | '012345678
';
10 | ctx.scroll.update();
11 | });
12 |
13 | describe('path()', function () {
14 | it('middle', function () {
15 | const path = ctx.scroll.path(7);
16 | const expected = [
17 | ['scroll', 7],
18 | ['block', 7],
19 | ['italic', 2],
20 | ['bold', 2],
21 | ['text', 2],
22 | ] as const;
23 | expect(path.length).toEqual(expected.length);
24 | path.forEach(function (position, i) {
25 | expect(position[0].statics.blotName).toEqual(expected[i][0]);
26 | expect(position[1]).toEqual(expected[i][1]);
27 | });
28 | });
29 |
30 | it('between blots', function () {
31 | const path = ctx.scroll.path(5);
32 | const expected = [
33 | ['scroll', 5],
34 | ['block', 5],
35 | ['italic', 0],
36 | ['bold', 0],
37 | ['text', 0],
38 | ] as const;
39 | expect(path.length).toEqual(expected.length);
40 | path.forEach(function (position, i) {
41 | expect(position[0].statics.blotName).toEqual(expected[i][0]);
42 | expect(position[1]).toEqual(expected[i][1]);
43 | });
44 | });
45 |
46 | it('inclusive', function () {
47 | const path = ctx.scroll.path(3, true);
48 | const expected = [
49 | ['scroll', 3],
50 | ['block', 3],
51 | ['bold', 3],
52 | ['text', 3],
53 | ] as const;
54 | expect(path.length).toEqual(expected.length);
55 | path.forEach(function (position, i) {
56 | expect(position[0].statics.blotName).toEqual(expected[i][0]);
57 | expect(position[1]).toEqual(expected[i][1]);
58 | });
59 | });
60 |
61 | it('last', function () {
62 | const path = ctx.scroll.path(9);
63 | const expected = [['scroll', 9]] as const;
64 | expect(path.length).toEqual(expected.length);
65 | path.forEach(function (position, i) {
66 | expect(position[0].statics.blotName).toEqual(expected[i][0]);
67 | expect(position[1]).toEqual(expected[i][1]);
68 | });
69 | });
70 | });
71 |
72 | it('delete all', function () {
73 | const wrapper = document.createElement('div');
74 | wrapper.appendChild(ctx.scroll.domNode);
75 | ctx.scroll.deleteAt(0, 9);
76 | expect(wrapper.firstChild).toEqual(ctx.scroll.domNode);
77 | });
78 |
79 | it('detach', async function () {
80 | vi.spyOn(ctx.scroll, 'optimize');
81 | ctx.scroll.domNode.innerHTML = 'Test';
82 | await new Promise((resolve) => {
83 | setTimeout(() => {
84 | expect(ctx.scroll.optimize).toHaveBeenCalledTimes(1);
85 | ctx.scroll.detach();
86 | ctx.scroll.domNode.innerHTML = '!';
87 | setTimeout(() => {
88 | expect(ctx.scroll.optimize).toHaveBeenCalledTimes(1);
89 | resolve();
90 | }, 1);
91 | }, 1);
92 | });
93 | });
94 |
95 | describe('scroll reference', function () {
96 | it('initialization', function () {
97 | expect(ctx.scroll).toEqual(ctx.scroll);
98 | ctx.scroll
99 | .descendants(() => true)
100 | .forEach((blot) => expect(blot.scroll).toEqual(ctx.scroll));
101 | });
102 |
103 | it('api change', function () {
104 | const blot = ctx.scroll.create('text', 'Test');
105 | ctx.scroll.appendChild(blot);
106 | expect(blot.scroll).toEqual(ctx.scroll);
107 | });
108 |
109 | it('user change', function () {
110 | ctx.scroll.domNode.innerHTML = '0123
';
111 | ctx.scroll.update();
112 | ctx.scroll
113 | .descendants(() => true)
114 | .forEach((blot) => expect(blot.scroll).toEqual(ctx.scroll));
115 | });
116 | });
117 | });
118 |
--------------------------------------------------------------------------------
/tests/unit/text.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, it, expect } from 'vitest';
2 | import TextBlot from '../../src/blot/text.js';
3 | import type { BlockBlot, InlineBlot } from '../../src/parchment.js';
4 | import { setupContextBeforeEach } from '../setup.js';
5 |
6 | describe('TextBlot', function () {
7 | const ctx = setupContextBeforeEach();
8 |
9 | it('constructor(node)', function () {
10 | const node = document.createTextNode('Test');
11 | const blot = new TextBlot(ctx.scroll, node);
12 | expect(blot['text']).toEqual('Test');
13 | expect(blot.domNode.data).toEqual('Test');
14 | });
15 |
16 | it('deleteAt() partial', function () {
17 | const blot = ctx.scroll.create('text', 'Test') as TextBlot;
18 | blot.deleteAt(1, 2);
19 | expect(blot.value()).toEqual('Tt');
20 | expect(blot.length()).toEqual(2);
21 | });
22 |
23 | it('deleteAt() all', function () {
24 | const container = ctx.scroll.create('inline') as InlineBlot;
25 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
26 | container.appendChild(textBlot);
27 | expect(container.domNode.firstChild).toEqual(textBlot.domNode);
28 | textBlot.deleteAt(0, 4);
29 | expect(textBlot.domNode.data).toEqual('');
30 | });
31 |
32 | it('insertAt() text', function () {
33 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
34 | textBlot.insertAt(1, 'ough');
35 | expect(textBlot.value()).toEqual('Toughest');
36 | });
37 |
38 | it('insertAt() other', function () {
39 | const container = ctx.scroll.create('inline') as InlineBlot;
40 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
41 | container.appendChild(textBlot);
42 | textBlot.insertAt(2, 'image', {});
43 | expect(textBlot.value()).toEqual('Te');
44 | expect(textBlot.next?.statics.blotName).toEqual('image');
45 | const nextNext = textBlot.next?.next;
46 | expect(nextNext instanceof TextBlot && nextNext.value()).toEqual('st');
47 | });
48 |
49 | it('split() middle', function () {
50 | const container = ctx.scroll.create('inline') as InlineBlot;
51 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
52 | container.appendChild(textBlot);
53 | const after = textBlot.split(2);
54 | expect(textBlot.value()).toEqual('Te');
55 | expect(after instanceof TextBlot && after.value()).toEqual('st');
56 | expect(textBlot.next).toEqual(after);
57 | expect(after instanceof TextBlot && after.prev).toEqual(textBlot);
58 | });
59 |
60 | it('split() noop', function () {
61 | const container = ctx.scroll.create('inline') as InlineBlot;
62 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
63 | container.appendChild(textBlot);
64 | const before = textBlot.split(0);
65 | const after = textBlot.split(4);
66 | expect(before).toEqual(textBlot);
67 | expect(after).toBe(null);
68 | });
69 |
70 | it('split() force', function () {
71 | const container = ctx.scroll.create('inline') as InlineBlot;
72 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
73 | container.appendChild(textBlot);
74 | const after = textBlot.split(4, true);
75 | expect(after).not.toEqual(textBlot);
76 | expect(after instanceof TextBlot && after.value()).toEqual('');
77 | expect(textBlot.next).toEqual(after);
78 | expect(after?.prev).toEqual(textBlot);
79 | });
80 |
81 | it('format wrap', function () {
82 | const container = ctx.scroll.create('inline') as InlineBlot;
83 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
84 | container.appendChild(textBlot);
85 | textBlot.formatAt(0, 4, 'bold', true);
86 | expect(textBlot.domNode.parentElement?.tagName).toEqual('STRONG');
87 | expect(textBlot.value()).toEqual('Test');
88 | });
89 |
90 | it('format null', function () {
91 | const container = ctx.scroll.create('inline') as InlineBlot;
92 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
93 | container.appendChild(textBlot);
94 | textBlot.formatAt(0, 4, 'bold', null);
95 | expect(textBlot.domNode.parentElement?.tagName).toEqual('SPAN');
96 | expect(textBlot.value()).toEqual('Test');
97 | });
98 |
99 | it('format split', function () {
100 | const container = ctx.scroll.create('block') as BlockBlot;
101 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
102 | container.appendChild(textBlot);
103 | textBlot.formatAt(1, 2, 'bold', true);
104 | expect(container.domNode.innerHTML).toEqual('Test');
105 | expect(textBlot.next?.statics.blotName).toEqual('bold');
106 | expect(textBlot.value()).toEqual('T');
107 | });
108 |
109 | it('index()', function () {
110 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
111 | expect(textBlot.index(textBlot.domNode, 2)).toEqual(2);
112 | expect(textBlot.index(document.body, 2)).toEqual(-1);
113 | });
114 |
115 | it('position()', function () {
116 | const textBlot = ctx.scroll.create('text', 'Test') as TextBlot;
117 | const [node, offset] = textBlot.position(2);
118 | expect(node).toEqual(textBlot.domNode);
119 | expect(offset).toEqual(2);
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "es6",
4 | "target": "es6",
5 | "lib": ["es2015", "dom", "dom.iterable"],
6 | "outDir": "./dist/esm",
7 | "declaration": true,
8 | "declarationDir": "./dist/typings",
9 | "strict": true,
10 | "moduleResolution": "Bundler",
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | "verbatimModuleSyntax": true
14 | },
15 | "include": ["src", "tests"]
16 | }
17 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig({
5 | build: {
6 | outDir: 'dist',
7 | lib: {
8 | name: 'Parchment',
9 | entry: './src/parchment.ts',
10 | formats: ['es', 'umd'],
11 | },
12 | sourcemap: true,
13 | },
14 | esbuild: {
15 | // only disabling keepNames is not supported yet
16 | minifyIdentifiers: false,
17 | },
18 | test: {
19 | browser: {
20 | enabled: true,
21 | provider: 'playwright',
22 | name: 'chromium',
23 | },
24 | },
25 | });
26 |
--------------------------------------------------------------------------------