Merge remote-tracking branch 'upstream/main'

This commit is contained in:
2026-08-26 09:31:34 +08:00
parent 9b8c4bfab6
commit 394eb0285d
34137 changed files with 3589424 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
import type LinkedList from '../../collection/linked-list.js';
import type LinkedNode from '../../collection/linked-node.js';
import type { RegistryDefinition } from '../../registry.js';
import Scope from '../../scope.js';
export interface BlotConstructor {
new (...args: any[]): Blot;
/**
* Creates corresponding DOM node
*/
create(value?: any): Node;
blotName: string;
tagName: string | string[];
scope: Scope;
className?: string;
requiredContainer?: BlotConstructor;
allowedChildren?: BlotConstructor[];
defaultChild?: BlotConstructor;
}
/**
* Blots are the basic building blocks of a Parchment document.
*
* Several basic implementations such as Block, Inline, and Embed are provided.
* In general you will want to extend one of these, instead of building from scratch.
* After implementation, blots need to be registered before usage.
*
* 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, which determine if it is inline or block.
*/
export interface Blot extends LinkedNode {
scroll: Root;
parent: Parent;
prev: Blot | null;
next: Blot | null;
domNode: Node;
statics: BlotConstructor;
attach(): void;
clone(): Blot;
detach(): void;
isolate(index: number, length: number): Blot;
/**
* For leaves, length of blot's value()
* For parents, sum of children's values
*/
length(): number;
/**
* Returns offset between this blot and an ancestor's
*/
offset(root?: Blot): number;
remove(): void;
replaceWith(name: string, value: any): Blot;
replaceWith(replacement: Blot): Blot;
split(index: number, force?: boolean): Blot | null;
wrap(name: string, value?: any): Parent;
wrap(wrapper: Parent): Parent;
deleteAt(index: number, length: number): void;
formatAt(index: number, length: number, name: string, value: any): void;
insertAt(index: number, value: string, def?: any): void;
/**
* Called after update cycle completes. Cannot change the value or length
* of the document, and any DOM operation must reduce complexity of the DOM
* tree. A shared context object is passed through all blots.
*/
optimize(context: { [key: string]: any }): void;
optimize(mutations: MutationRecord[], context: { [key: string]: any }): void;
/**
* Called when blot changes, with the mutation records of its change.
* Internal records of the blot values can be updated, and modifications of
* the blot itself is permitted. Can be trigger from user change or API call.
* A shared context object is passed through all blots.
*/
update(mutations: MutationRecord[], context: { [key: string]: any }): void;
}
export interface Parent extends Blot {
children: LinkedList<Blot>;
domNode: HTMLElement;
appendChild(child: Blot): void;
descendant<T>(type: new () => T, index: number): [T, number];
descendant<T>(matcher: (blot: Blot) => boolean, index: number): [T, number];
descendants<T>(type: new () => T, index: number, length: number): T[];
descendants<T>(
matcher: (blot: Blot) => boolean,
index: number,
length: number,
): T[];
insertBefore(child: Blot, refNode?: Blot | null): void;
moveChildren(parent: Parent, refNode?: Blot | null): void;
path(index: number, inclusive?: boolean): [Blot, number][];
removeChild(child: Blot): void;
unwrap(): void;
}
export interface Root extends Parent {
create(input: Node | string | Scope, value?: any): Blot;
find(node: Node | null, bubble?: boolean): Blot | null;
query(query: string | Node | Scope, scope?: Scope): RegistryDefinition | null;
}
export interface Formattable extends Blot {
/**
* Apply format to blot. Should not pass onto child or other blot.
*/
format(name: string, value: any): void;
/**
* Return formats represented by blot, including from Attributors.
*/
formats(): { [index: string]: any };
}
export interface Leaf extends Blot {
index(node: Node, offset: number): number;
position(index: number, inclusive: boolean): [Node, number];
value(): any;
}
+48
View File
@@ -0,0 +1,48 @@
import Scope from '../../scope.js';
import BlockBlot from '../block.js';
import ParentBlot from './parent.js';
class ContainerBlot extends ParentBlot {
public static blotName = 'container';
public static scope = Scope.BLOCK_BLOT;
public static tagName: string | string[];
public prev!: BlockBlot | ContainerBlot | null;
public next!: BlockBlot | ContainerBlot | null;
public checkMerge(): boolean {
return (
this.next !== null && this.next.statics.blotName === this.statics.blotName
);
}
public deleteAt(index: number, length: number): void {
super.deleteAt(index, length);
this.enforceAllowedChildren();
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
super.formatAt(index, length, name, value);
this.enforceAllowedChildren();
}
public insertAt(index: number, value: string, def?: any): void {
super.insertAt(index, value, def);
this.enforceAllowedChildren();
}
public optimize(context: { [key: string]: any }): void {
super.optimize(context);
if (this.children.length > 0 && this.next != null && this.checkMerge()) {
this.next.moveChildren(this);
this.next.remove();
}
}
}
export default ContainerBlot;
+57
View File
@@ -0,0 +1,57 @@
import Scope from '../../scope.js';
import type { Leaf } from './blot.js';
import ShadowBlot from './shadow.js';
class LeafBlot extends ShadowBlot implements Leaf {
public static scope = Scope.INLINE_BLOT;
/**
* Returns the value represented by domNode if it is this Blot's type
* No checking that domNode can represent this Blot type is required so
* applications needing it should check externally before calling.
*/
public static value(_domNode: Node): any {
return true;
}
/**
* Given location represented by node and offset from DOM Selection Range,
* return index to that location.
*/
public index(node: Node, offset: number): number {
if (
this.domNode === node ||
this.domNode.compareDocumentPosition(node) &
Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return Math.min(offset, 1);
}
return -1;
}
/**
* Given index to location within blot, return node and offset representing
* that location, consumable by DOM Selection Range
*/
public position(index: number, _inclusive?: boolean): [Node, number] {
const childNodes: Node[] = Array.from(this.parent.domNode.childNodes);
let offset = childNodes.indexOf(this.domNode);
if (index > 0) {
offset += 1;
}
return [this.parent.domNode, offset];
}
/**
* Return value represented by this blot
* Should not change without interaction from API or
* user change detectable by update()
*/
public value(): any {
return {
[this.statics.blotName]: this.statics.value(this.domNode) || true,
};
}
}
export default LeafBlot;
+400
View File
@@ -0,0 +1,400 @@
import LinkedList from '../../collection/linked-list.js';
import ParchmentError from '../../error.js';
import Scope from '../../scope.js';
import type { Blot, BlotConstructor, Parent, Root } from './blot.js';
import ShadowBlot from './shadow.js';
function makeAttachedBlot(node: Node, scroll: Root): Blot {
const found = scroll.find(node);
if (found) return found;
try {
return scroll.create(node);
} catch (e) {
const blot = scroll.create(Scope.INLINE);
Array.from(node.childNodes).forEach((child: Node) => {
blot.domNode.appendChild(child);
});
if (node.parentNode) {
node.parentNode.replaceChild(blot.domNode, node);
}
blot.attach();
return blot;
}
}
class ParentBlot extends ShadowBlot implements Parent {
/**
* Whitelist array of Blots that can be direct children.
*/
public static allowedChildren?: BlotConstructor[];
/**
* Default child blot to be inserted if this blot becomes empty.
*/
public static defaultChild?: BlotConstructor;
public static uiClass = '';
public children!: LinkedList<Blot>;
public domNode!: HTMLElement;
public uiNode: HTMLElement | null = null;
constructor(scroll: Root, domNode: Node) {
super(scroll, domNode);
this.build();
}
public appendChild(other: Blot): void {
this.insertBefore(other);
}
public attach(): void {
super.attach();
this.children.forEach((child) => {
child.attach();
});
}
public attachUI(node: HTMLElement): void {
if (this.uiNode != null) {
this.uiNode.remove();
}
this.uiNode = node;
if (ParentBlot.uiClass) {
this.uiNode.classList.add(ParentBlot.uiClass);
}
this.uiNode.setAttribute('contenteditable', 'false');
this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
}
/**
* Called during construction, should fill its own children LinkedList.
*/
public build(): void {
this.children = new LinkedList<Blot>();
// Need to be reversed for if DOM nodes already in order
Array.from(this.domNode.childNodes)
.filter((node: Node) => node !== this.uiNode)
.reverse()
.forEach((node: Node) => {
try {
const child = makeAttachedBlot(node, this.scroll);
this.insertBefore(child, this.children.head || undefined);
} catch (err) {
if (err instanceof ParchmentError) {
return;
} else {
throw err;
}
}
});
}
public deleteAt(index: number, length: number): void {
if (index === 0 && length === this.length()) {
return this.remove();
}
this.children.forEachAt(index, length, (child, offset, childLength) => {
child.deleteAt(offset, childLength);
});
}
public descendant<T extends Blot>(
criteria: new (...args: any[]) => T,
index: number,
): [T | null, number];
public descendant(
criteria: (blot: Blot) => boolean,
index: number,
): [Blot | null, number];
public descendant(criteria: any, index = 0): [Blot | null, number] {
const [child, offset] = this.children.find(index);
if (
(criteria.blotName == null && criteria(child)) ||
(criteria.blotName != null && child instanceof criteria)
) {
return [child as any, offset];
} else if (child instanceof ParentBlot) {
return child.descendant(criteria, offset);
} else {
return [null, -1];
}
}
public descendants<T extends Blot>(
criteria: new (...args: any[]) => T,
index?: number,
length?: number,
): T[];
public descendants(
criteria: (blot: Blot) => boolean,
index?: number,
length?: number,
): Blot[];
public descendants(
criteria: any,
index = 0,
length: number = Number.MAX_VALUE,
): Blot[] {
let descendants: Blot[] = [];
let lengthLeft = length;
this.children.forEachAt(
index,
length,
(child: Blot, childIndex: number, childLength: number) => {
if (
(criteria.blotName == null && criteria(child)) ||
(criteria.blotName != null && child instanceof criteria)
) {
descendants.push(child);
}
if (child instanceof ParentBlot) {
descendants = descendants.concat(
child.descendants(criteria, childIndex, lengthLeft),
);
}
lengthLeft -= childLength;
},
);
return descendants;
}
public detach(): void {
this.children.forEach((child) => {
child.detach();
});
super.detach();
}
public enforceAllowedChildren(): void {
let done = false;
this.children.forEach((child: Blot) => {
if (done) {
return;
}
const allowed = this.statics.allowedChildren.some(
(def: BlotConstructor) => child instanceof def,
);
if (allowed) {
return;
}
if (child.statics.scope === Scope.BLOCK_BLOT) {
if (child.next != null) {
this.splitAfter(child);
}
if (child.prev != null) {
this.splitAfter(child.prev);
}
child.parent.unwrap();
done = true;
} else if (child instanceof ParentBlot) {
child.unwrap();
} else {
child.remove();
}
});
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
this.children.forEachAt(index, length, (child, offset, childLength) => {
child.formatAt(offset, childLength, name, value);
});
}
public insertAt(index: number, value: string, def?: any): void {
const [child, offset] = this.children.find(index);
if (child) {
child.insertAt(offset, value, def);
} else {
const blot =
def == null
? this.scroll.create('text', value)
: this.scroll.create(value, def);
this.appendChild(blot);
}
}
public insertBefore(childBlot: Blot, refBlot?: Blot | null): void {
if (childBlot.parent != null) {
childBlot.parent.children.remove(childBlot);
}
let refDomNode: Node | null = null;
this.children.insertBefore(childBlot, refBlot || null);
childBlot.parent = this;
if (refBlot != null) {
refDomNode = refBlot.domNode;
}
if (
this.domNode.parentNode !== childBlot.domNode ||
this.domNode.nextSibling !== refDomNode
) {
this.domNode.insertBefore(childBlot.domNode, refDomNode);
}
childBlot.attach();
}
public length(): number {
return this.children.reduce((memo, child) => {
return memo + child.length();
}, 0);
}
public moveChildren(targetParent: Parent, refNode?: Blot | null): void {
this.children.forEach((child) => {
targetParent.insertBefore(child, refNode);
});
}
public optimize(context?: { [key: string]: any }): void {
super.optimize(context);
this.enforceAllowedChildren();
if (this.uiNode != null && this.uiNode !== this.domNode.firstChild) {
this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
}
if (this.children.length === 0) {
if (this.statics.defaultChild != null) {
const child = this.scroll.create(this.statics.defaultChild.blotName);
this.appendChild(child);
// TODO double check if necessary
// child.optimize(context);
} else {
this.remove();
}
}
}
public path(index: number, inclusive = false): [Blot, number][] {
const [child, offset] = this.children.find(index, inclusive);
const position: [Blot, number][] = [[this, index]];
if (child instanceof ParentBlot) {
return position.concat(child.path(offset, inclusive));
} else if (child != null) {
position.push([child, offset]);
}
return position;
}
public removeChild(child: Blot): void {
this.children.remove(child);
}
public replaceWith(name: string | Blot, value?: any): Blot {
const replacement =
typeof name === 'string' ? this.scroll.create(name, value) : name;
if (replacement instanceof ParentBlot) {
this.moveChildren(replacement);
}
return super.replaceWith(replacement);
}
public split(index: number, force = false): Blot | null {
if (!force) {
if (index === 0) {
return this;
}
if (index === this.length()) {
return this.next;
}
}
const after = this.clone() as ParentBlot;
if (this.parent) {
this.parent.insertBefore(after, this.next || undefined);
}
this.children.forEachAt(index, this.length(), (child, offset, _length) => {
const split = child.split(offset, force);
if (split != null) {
after.appendChild(split);
}
});
return after;
}
public splitAfter(child: Blot): Parent {
const after = this.clone() as ParentBlot;
while (child.next != null) {
after.appendChild(child.next);
}
if (this.parent) {
this.parent.insertBefore(after, this.next || undefined);
}
return after;
}
public unwrap(): void {
if (this.parent) {
this.moveChildren(this.parent, this.next || undefined);
}
this.remove();
}
public update(
mutations: MutationRecord[],
_context: { [key: string]: any },
): void {
const addedNodes: Node[] = [];
const removedNodes: Node[] = [];
mutations.forEach((mutation) => {
if (mutation.target === this.domNode && mutation.type === 'childList') {
addedNodes.push(...mutation.addedNodes);
removedNodes.push(...mutation.removedNodes);
}
});
removedNodes.forEach((node: Node) => {
// Check node has actually been removed
// One exception is Chrome does not immediately remove IFRAMEs
// from DOM but MutationRecord is correct in its reported removal
if (
node.parentNode != null &&
// @ts-expect-error Fix me later
node.tagName !== 'IFRAME' &&
document.body.compareDocumentPosition(node) &
Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return;
}
const blot = this.scroll.find(node);
if (blot == null) {
return;
}
if (
blot.domNode.parentNode == null ||
blot.domNode.parentNode === this.domNode
) {
blot.detach();
}
});
addedNodes
.filter((node) => {
return node.parentNode === this.domNode && node !== this.uiNode;
})
.sort((a, b) => {
if (a === b) {
return 0;
}
if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) {
return 1;
}
return -1;
})
.forEach((node) => {
let refBlot: Blot | null = null;
if (node.nextSibling != null) {
refBlot = this.scroll.find(node.nextSibling);
}
const blot = makeAttachedBlot(node, this.scroll);
if (blot.next !== refBlot || blot.next == null) {
if (blot.parent != null) {
blot.parent.removeChild(this);
}
this.insertBefore(blot, refBlot || undefined);
}
});
this.enforceAllowedChildren();
}
}
export default ParentBlot;
+188
View File
@@ -0,0 +1,188 @@
import ParchmentError from '../../error.js';
import Registry from '../../registry.js';
import Scope from '../../scope.js';
import type {
Blot,
BlotConstructor,
Formattable,
Parent,
Root,
} from './blot.js';
class ShadowBlot implements Blot {
public static blotName = 'abstract';
public static className: string;
public static requiredContainer: BlotConstructor;
public static scope: Scope;
public static tagName: string | string[];
public static create(rawValue?: unknown): Node {
if (this.tagName == null) {
throw new ParchmentError('Blot definition missing tagName');
}
let node: HTMLElement;
let value: string | number | undefined;
if (Array.isArray(this.tagName)) {
if (typeof rawValue === 'string') {
value = rawValue.toUpperCase();
if (parseInt(value, 10).toString() === value) {
value = parseInt(value, 10);
}
} else if (typeof rawValue === 'number') {
value = rawValue;
}
if (typeof value === 'number') {
node = document.createElement(this.tagName[value - 1]);
} else if (value && this.tagName.indexOf(value) > -1) {
node = document.createElement(value);
} else {
node = document.createElement(this.tagName[0]);
}
} else {
node = document.createElement(this.tagName);
}
if (this.className) {
node.classList.add(this.className);
}
return node;
}
public prev: Blot | null;
public next: Blot | null;
// @ts-expect-error Fix me later
public parent: Parent;
// Hack for accessing inherited static methods
get statics(): any {
return this.constructor;
}
constructor(
public scroll: Root,
public domNode: Node,
) {
Registry.blots.set(domNode, this);
this.prev = null;
this.next = null;
}
public attach(): void {
// Nothing to do
}
public clone(): Blot {
const domNode = this.domNode.cloneNode(false);
return this.scroll.create(domNode);
}
public detach(): void {
if (this.parent != null) {
this.parent.removeChild(this);
}
Registry.blots.delete(this.domNode);
}
public deleteAt(index: number, length: number): void {
const blot = this.isolate(index, length);
blot.remove();
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
const blot = this.isolate(index, length);
if (this.scroll.query(name, Scope.BLOT) != null && value) {
blot.wrap(name, value);
} else if (this.scroll.query(name, Scope.ATTRIBUTE) != null) {
const parent = this.scroll.create(this.statics.scope) as Parent &
Formattable;
blot.wrap(parent);
parent.format(name, value);
}
}
public insertAt(index: number, value: string, def?: any): void {
const blot =
def == null
? this.scroll.create('text', value)
: this.scroll.create(value, def);
const ref = this.split(index);
this.parent.insertBefore(blot, ref || undefined);
}
public isolate(index: number, length: number): Blot {
const target = this.split(index);
if (target == null) {
throw new Error('Attempt to isolate at end');
}
target.split(length);
return target;
}
public length(): number {
return 1;
}
public offset(root: Blot = this.parent): number {
if (this.parent == null || this === root) {
return 0;
}
return this.parent.children.offset(this) + this.parent.offset(root);
}
public optimize(_context?: { [key: string]: any }): void {
if (
this.statics.requiredContainer &&
!(this.parent instanceof this.statics.requiredContainer)
) {
this.wrap(this.statics.requiredContainer.blotName);
}
}
public remove(): void {
if (this.domNode.parentNode != null) {
this.domNode.parentNode.removeChild(this.domNode);
}
this.detach();
}
public replaceWith(name: string | Blot, value?: any): Blot {
const replacement =
typeof name === 'string' ? this.scroll.create(name, value) : name;
if (this.parent != null) {
this.parent.insertBefore(replacement, this.next || undefined);
this.remove();
}
return replacement;
}
public split(index: number, _force?: boolean): Blot | null {
return index === 0 ? this : this.next;
}
public update(
_mutations: MutationRecord[],
_context: { [key: string]: any },
): void {
// Nothing to do by default
}
public wrap(name: string | Parent, value?: any): Parent {
const wrapper =
typeof name === 'string'
? (this.scroll.create(name, value) as Parent)
: name;
if (this.parent != null) {
this.parent.insertBefore(wrapper, this.next || undefined);
}
if (typeof wrapper.appendChild !== 'function') {
throw new ParchmentError(`Cannot wrap ${name}`);
}
wrapper.appendChild(this);
return wrapper;
}
}
export default ShadowBlot;
+123
View File
@@ -0,0 +1,123 @@
import Attributor from '../attributor/attributor.js';
import AttributorStore from '../attributor/store.js';
import Scope from '../scope.js';
import type {
Blot,
BlotConstructor,
Formattable,
Root,
} from './abstract/blot.js';
import LeafBlot from './abstract/leaf.js';
import ParentBlot from './abstract/parent.js';
import InlineBlot from './inline.js';
class BlockBlot extends ParentBlot implements Formattable {
public static blotName = 'block';
public static scope = Scope.BLOCK_BLOT;
public static tagName: string | string[] = 'P';
public static allowedChildren: BlotConstructor[] = [
InlineBlot,
BlockBlot,
LeafBlot,
];
static create(value?: unknown) {
return super.create(value) as HTMLElement;
}
public static formats(domNode: HTMLElement, scroll: Root): any {
const match = scroll.query(BlockBlot.blotName);
if (
match != null &&
domNode.tagName === (match as BlotConstructor).tagName
) {
return undefined;
} else if (typeof this.tagName === 'string') {
return true;
} else if (Array.isArray(this.tagName)) {
return domNode.tagName.toLowerCase();
}
}
protected attributes: AttributorStore;
constructor(scroll: Root, domNode: Node) {
super(scroll, domNode);
this.attributes = new AttributorStore(this.domNode);
}
public format(name: string, value: any): void {
const format = this.scroll.query(name, Scope.BLOCK);
if (format == null) {
return;
} else if (format instanceof Attributor) {
this.attributes.attribute(format, value);
} else if (name === this.statics.blotName && !value) {
this.replaceWith(BlockBlot.blotName);
} else if (
value &&
(name !== this.statics.blotName || this.formats()[name] !== value)
) {
this.replaceWith(name, value);
}
}
public formats(): { [index: string]: any } {
const formats = this.attributes.values();
const format = this.statics.formats(this.domNode, this.scroll);
if (format != null) {
formats[this.statics.blotName] = format;
}
return formats;
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
if (this.scroll.query(name, Scope.BLOCK) != null) {
this.format(name, value);
} else {
super.formatAt(index, length, name, value);
}
}
public insertAt(index: number, value: string, def?: any): void {
if (def == null || this.scroll.query(value, Scope.INLINE) != null) {
// Insert text or inline
super.insertAt(index, value, def);
} else {
const after = this.split(index);
if (after != null) {
const blot = this.scroll.create(value, def);
after.parent.insertBefore(blot, after);
} else {
throw new Error('Attempt to insertAt after block boundaries');
}
}
}
public replaceWith(name: string | Blot, value?: any): Blot {
const replacement = super.replaceWith(name, value) as BlockBlot;
this.attributes.copy(replacement);
return replacement;
}
public update(
mutations: MutationRecord[],
context: { [key: string]: any },
): void {
super.update(mutations, context);
const attributeChanged = mutations.some(
(mutation) =>
mutation.target === this.domNode && mutation.type === 'attributes',
);
if (attributeChanged) {
this.attributes.build();
}
}
}
export default BlockBlot;
+34
View File
@@ -0,0 +1,34 @@
import type { Formattable, Root } from './abstract/blot.js';
import LeafBlot from './abstract/leaf.js';
class EmbedBlot extends LeafBlot implements Formattable {
public static formats(_domNode: HTMLElement, _scroll: Root): any {
return undefined;
}
public format(name: string, value: any): void {
// super.formatAt wraps, which is what we want in general,
// but this allows subclasses to overwrite for formats
// that just apply to particular embeds
super.formatAt(0, this.length(), name, value);
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
if (index === 0 && length === this.length()) {
this.format(name, value);
} else {
super.formatAt(index, length, name, value);
}
}
public formats(): { [index: string]: any } {
return this.statics.formats(this.domNode, this.scroll);
}
}
export default EmbedBlot;
+159
View File
@@ -0,0 +1,159 @@
import Attributor from '../attributor/attributor.js';
import AttributorStore from '../attributor/store.js';
import Scope from '../scope.js';
import type {
Blot,
BlotConstructor,
Formattable,
Parent,
Root,
} from './abstract/blot.js';
import LeafBlot from './abstract/leaf.js';
import ParentBlot from './abstract/parent.js';
// Shallow object comparison
function isEqual(
obj1: Record<string, unknown>,
obj2: Record<string, unknown>,
): boolean {
if (Object.keys(obj1).length !== Object.keys(obj2).length) {
return false;
}
for (const prop in obj1) {
if (obj1[prop] !== obj2[prop]) {
return false;
}
}
return true;
}
class InlineBlot extends ParentBlot implements Formattable {
public static allowedChildren: BlotConstructor[] = [InlineBlot, LeafBlot];
public static blotName = 'inline';
public static scope = Scope.INLINE_BLOT;
public static tagName: string | string[] = 'SPAN';
static create(value?: unknown) {
return super.create(value) as HTMLElement;
}
public static formats(domNode: HTMLElement, scroll: Root): any {
const match = scroll.query(InlineBlot.blotName);
if (
match != null &&
domNode.tagName === (match as BlotConstructor).tagName
) {
return undefined;
} else if (typeof this.tagName === 'string') {
return true;
} else if (Array.isArray(this.tagName)) {
return domNode.tagName.toLowerCase();
}
return undefined;
}
protected attributes: AttributorStore;
constructor(scroll: Root, domNode: Node) {
super(scroll, domNode);
this.attributes = new AttributorStore(this.domNode);
}
public format(name: string, value: any): void {
if (name === this.statics.blotName && !value) {
this.children.forEach((child) => {
if (!(child instanceof InlineBlot)) {
child = child.wrap(InlineBlot.blotName, true);
}
this.attributes.copy(child as InlineBlot);
});
this.unwrap();
} else {
const format = this.scroll.query(name, Scope.INLINE);
if (format == null) {
return;
}
if (format instanceof Attributor) {
this.attributes.attribute(format, value);
} else if (
value &&
(name !== this.statics.blotName || this.formats()[name] !== value)
) {
this.replaceWith(name, value);
}
}
}
public formats(): { [index: string]: any } {
const formats = this.attributes.values();
const format = this.statics.formats(this.domNode, this.scroll);
if (format != null) {
formats[this.statics.blotName] = format;
}
return formats;
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
if (
this.formats()[name] != null ||
this.scroll.query(name, Scope.ATTRIBUTE)
) {
const blot = this.isolate(index, length) as InlineBlot;
blot.format(name, value);
} else {
super.formatAt(index, length, name, value);
}
}
public optimize(context: { [key: string]: any }): void {
super.optimize(context);
const formats = this.formats();
if (Object.keys(formats).length === 0) {
return this.unwrap(); // unformatted span
}
const next = this.next;
if (
next instanceof InlineBlot &&
next.prev === this &&
isEqual(formats, next.formats())
) {
next.moveChildren(this);
next.remove();
}
}
public replaceWith(name: string | Blot, value?: any): Blot {
const replacement = super.replaceWith(name, value) as InlineBlot;
this.attributes.copy(replacement);
return replacement;
}
public update(
mutations: MutationRecord[],
context: { [key: string]: any },
): void {
super.update(mutations, context);
const attributeChanged = mutations.some(
(mutation) =>
mutation.target === this.domNode && mutation.type === 'attributes',
);
if (attributeChanged) {
this.attributes.build();
}
}
public wrap(name: string | Parent, value?: any): Parent {
const wrapper = super.wrap(name, value);
if (wrapper instanceof InlineBlot) {
this.attributes.move(wrapper);
}
return wrapper;
}
}
export default InlineBlot;
+216
View File
@@ -0,0 +1,216 @@
import Registry, { type RegistryDefinition } from '../registry.js';
import Scope from '../scope.js';
import type { Blot, BlotConstructor, Root } from './abstract/blot.js';
import ContainerBlot from './abstract/container.js';
import ParentBlot from './abstract/parent.js';
import BlockBlot from './block.js';
const OBSERVER_CONFIG = {
attributes: true,
characterData: true,
characterDataOldValue: true,
childList: true,
subtree: true,
};
const MAX_OPTIMIZE_ITERATIONS = 100;
class ScrollBlot extends ParentBlot implements Root {
public static blotName = 'scroll';
public static defaultChild = BlockBlot;
public static allowedChildren: BlotConstructor[] = [BlockBlot, ContainerBlot];
public static scope = Scope.BLOCK_BLOT;
public static tagName = 'DIV';
public observer: MutationObserver;
constructor(
public registry: Registry,
node: HTMLDivElement,
) {
// @ts-expect-error scroll is the root with no parent
super(null, node);
this.scroll = this;
this.build();
this.observer = new MutationObserver((mutations: MutationRecord[]) => {
this.update(mutations);
});
this.observer.observe(this.domNode, OBSERVER_CONFIG);
this.attach();
}
public create(input: Node | string | Scope, value?: any): Blot {
return this.registry.create(this, input, value);
}
public find(node: Node | null, bubble = false): Blot | null {
const blot = this.registry.find(node, bubble);
if (!blot) {
return null;
}
if (blot.scroll === this) {
return blot;
}
return bubble ? this.find(blot.scroll.domNode.parentNode, true) : null;
}
public query(
query: string | Node | Scope,
scope: Scope = Scope.ANY,
): RegistryDefinition | null {
return this.registry.query(query, scope);
}
public register(...definitions: RegistryDefinition[]) {
return this.registry.register(...definitions);
}
public build(): void {
if (this.scroll == null) {
return;
}
super.build();
}
public detach(): void {
super.detach();
this.observer.disconnect();
}
public deleteAt(index: number, length: number): void {
this.update();
if (index === 0 && length === this.length()) {
this.children.forEach((child) => {
child.remove();
});
} else {
super.deleteAt(index, length);
}
}
public formatAt(
index: number,
length: number,
name: string,
value: any,
): void {
this.update();
super.formatAt(index, length, name, value);
}
public insertAt(index: number, value: string, def?: any): void {
this.update();
super.insertAt(index, value, def);
}
public optimize(context?: { [key: string]: any }): void;
public optimize(
mutations: MutationRecord[],
context: { [key: string]: any },
): void;
public optimize(mutations: any = [], context: any = {}): void {
super.optimize(context);
const mutationsMap = context.mutationsMap || new WeakMap();
// We must modify mutations directly, cannot make copy and then modify
let records = Array.from(this.observer.takeRecords());
// Array.push currently seems to be implemented by a non-tail recursive function
// so we cannot just mutations.push.apply(mutations, this.observer.takeRecords());
while (records.length > 0) {
mutations.push(records.pop());
}
const mark = (blot: Blot | null, markParent = true): void => {
if (blot == null || blot === this) {
return;
}
if (blot.domNode.parentNode == null) {
return;
}
if (!mutationsMap.has(blot.domNode)) {
mutationsMap.set(blot.domNode, []);
}
if (markParent) {
mark(blot.parent);
}
};
const optimize = (blot: Blot): void => {
// Post-order traversal
if (!mutationsMap.has(blot.domNode)) {
return;
}
if (blot instanceof ParentBlot) {
blot.children.forEach(optimize);
}
mutationsMap.delete(blot.domNode);
blot.optimize(context);
};
let remaining = mutations;
for (let i = 0; remaining.length > 0; i += 1) {
if (i >= MAX_OPTIMIZE_ITERATIONS) {
throw new Error('[Parchment] Maximum optimize iterations reached');
}
remaining.forEach((mutation: MutationRecord) => {
const blot = this.find(mutation.target, true);
if (blot == null) {
return;
}
if (blot.domNode === mutation.target) {
if (mutation.type === 'childList') {
mark(this.find(mutation.previousSibling, false));
Array.from(mutation.addedNodes).forEach((node: Node) => {
const child = this.find(node, false);
mark(child, false);
if (child instanceof ParentBlot) {
child.children.forEach((grandChild: Blot) => {
mark(grandChild, false);
});
}
});
} else if (mutation.type === 'attributes') {
mark(blot.prev);
}
}
mark(blot);
});
this.children.forEach(optimize);
remaining = Array.from(this.observer.takeRecords());
records = remaining.slice();
while (records.length > 0) {
mutations.push(records.pop());
}
}
}
public update(
mutations?: MutationRecord[],
context: { [key: string]: any } = {},
): void {
mutations = mutations || this.observer.takeRecords();
const mutationsMap = new WeakMap();
mutations
.map((mutation: MutationRecord) => {
const blot = this.find(mutation.target, true);
if (blot == null) {
return null;
}
if (mutationsMap.has(blot.domNode)) {
mutationsMap.get(blot.domNode).push(mutation);
return null;
} else {
mutationsMap.set(blot.domNode, [mutation]);
return blot;
}
})
.forEach((blot: Blot | null) => {
if (blot != null && blot !== this && mutationsMap.has(blot.domNode)) {
blot.update(mutationsMap.get(blot.domNode) || [], context);
}
});
context.mutationsMap = mutationsMap;
if (mutationsMap.has(this.domNode)) {
super.update(mutationsMap.get(this.domNode), context);
}
this.optimize(mutations, context);
}
}
export default ScrollBlot;
+100
View File
@@ -0,0 +1,100 @@
import Scope from '../scope.js';
import type { Blot, Leaf, Root } from './abstract/blot.js';
import LeafBlot from './abstract/leaf.js';
class TextBlot extends LeafBlot implements Leaf {
public static readonly blotName = 'text';
public static scope = Scope.INLINE_BLOT;
public static create(value: string): Text {
return document.createTextNode(value);
}
public static value(domNode: Text): string {
return domNode.data;
}
public domNode!: Text;
protected text: string;
constructor(scroll: Root, node: Node) {
super(scroll, node);
this.text = this.statics.value(this.domNode);
}
public deleteAt(index: number, length: number): void {
this.domNode.data = this.text =
this.text.slice(0, index) + this.text.slice(index + length);
}
public index(node: Node, offset: number): number {
if (this.domNode === node) {
return offset;
}
return -1;
}
public insertAt(index: number, value: string, def?: any): void {
if (def == null) {
this.text = this.text.slice(0, index) + value + this.text.slice(index);
this.domNode.data = this.text;
} else {
super.insertAt(index, value, def);
}
}
public length(): number {
return this.text.length;
}
public optimize(context: { [key: string]: any }): void {
super.optimize(context);
this.text = this.statics.value(this.domNode);
if (this.text.length === 0) {
this.remove();
} else if (this.next instanceof TextBlot && this.next.prev === this) {
this.insertAt(this.length(), (this.next as TextBlot).value());
this.next.remove();
}
}
public position(index: number, _inclusive = false): [Node, number] {
return [this.domNode, index];
}
public split(index: number, force = false): Blot | null {
if (!force) {
if (index === 0) {
return this;
}
if (index === this.length()) {
return this.next;
}
}
const after = this.scroll.create(this.domNode.splitText(index));
this.parent.insertBefore(after, this.next || undefined);
this.text = this.statics.value(this.domNode);
return after;
}
public update(
mutations: MutationRecord[],
_context: { [key: string]: any },
): void {
if (
mutations.some((mutation) => {
return (
mutation.type === 'characterData' && mutation.target === this.domNode
);
})
) {
this.text = this.statics.value(this.domNode);
}
}
public value(): string {
return this.text;
}
}
export default TextBlot;